|
| 1 | +package bitcoin.base; |
| 2 | + |
| 3 | +import java.math.BigDecimal; |
| 4 | +import java.math.RoundingMode; |
| 5 | +import java.util.Set; |
| 6 | +import java.util.regex.Pattern; |
| 7 | + |
| 8 | +/** |
| 9 | + * Central validation policy for Bitcoin RPC operations exposed by the server. |
| 10 | + * |
| 11 | + * The policy intentionally permits only the RPC methods used by the application |
| 12 | + * and validates monetary inputs before they reach bitcoin-cli. |
| 13 | + */ |
| 14 | +public final class BitcoinRpcPolicy |
| 15 | +{ |
| 16 | + private static final Set<String> ALLOWED_RPC_METHODS = Set.of( |
| 17 | + "stop", |
| 18 | + "loadwallet", |
| 19 | + "unloadwallet", |
| 20 | + "createwallet", |
| 21 | + "getwalletinfo", |
| 22 | + "getbalance", |
| 23 | + "getnewaddress", |
| 24 | + "getblockchaininfo", |
| 25 | + "getblockcount", |
| 26 | + "sendtoaddress" |
| 27 | + ); |
| 28 | + |
| 29 | + private static final Pattern WALLET_NAME = Pattern.compile("[A-Za-z0-9._ -]{1,128}"); |
| 30 | + private static final Pattern BITCOIN_ADDRESS = Pattern.compile("(?:[123][1-9A-HJ-NP-Za-km-z]{25,90}|bc1[ac-hj-np-z02-9]{11,87}|tb1[ac-hj-np-z02-9]{11,87}|bcrt1[ac-hj-np-z02-9]{11,87})"); |
| 31 | + |
| 32 | + private BitcoinRpcPolicy() {} |
| 33 | + |
| 34 | + public static boolean isAllowed(final String method) |
| 35 | + { |
| 36 | + return method != null && ALLOWED_RPC_METHODS.contains(method); |
| 37 | + } |
| 38 | + |
| 39 | + public static void requireAllowed(final String method) |
| 40 | + { |
| 41 | + if (!isAllowed(method)) |
| 42 | + throw new IllegalArgumentException("Bitcoin RPC method is not permitted: " + method); |
| 43 | + } |
| 44 | + |
| 45 | + public static String requireWalletName(final String name) |
| 46 | + { |
| 47 | + if (name == null || !WALLET_NAME.matcher(name).matches()) |
| 48 | + throw new IllegalArgumentException("Invalid Bitcoin wallet name"); |
| 49 | + return name; |
| 50 | + } |
| 51 | + |
| 52 | + public static String requireAddress(final String address) |
| 53 | + { |
| 54 | + if (address == null || !BITCOIN_ADDRESS.matcher(address).matches()) |
| 55 | + throw new IllegalArgumentException("Invalid Bitcoin address format"); |
| 56 | + return address; |
| 57 | + } |
| 58 | + |
| 59 | + /** Parse BTC without floating-point conversion and convert exactly to satoshis. */ |
| 60 | + public static long requireSatoshis(final String amount) |
| 61 | + { |
| 62 | + if (amount == null || amount.isBlank()) |
| 63 | + throw new IllegalArgumentException("BTC amount is required"); |
| 64 | + |
| 65 | + final BigDecimal value; |
| 66 | + try |
| 67 | + { |
| 68 | + value = new BigDecimal(amount.trim()); |
| 69 | + } |
| 70 | + catch (NumberFormatException e) |
| 71 | + { |
| 72 | + throw new IllegalArgumentException("Invalid BTC amount", e); |
| 73 | + } |
| 74 | + |
| 75 | + if (value.signum() <= 0 || value.scale() > 8) |
| 76 | + throw new IllegalArgumentException("BTC amount must be positive and use at most 8 decimal places"); |
| 77 | + |
| 78 | + final BigDecimal satoshis = value.movePointRight(8).setScale(0, RoundingMode.UNNECESSARY); |
| 79 | + if (satoshis.compareTo(BigDecimal.valueOf(Long.MAX_VALUE)) > 0) |
| 80 | + throw new IllegalArgumentException("BTC amount is too large"); |
| 81 | + return satoshis.longValueExact(); |
| 82 | + } |
| 83 | +} |
0 commit comments