diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 880acfe3..42c0cbe5 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -911,6 +911,42 @@ public class CrossChainResource { } } + @POST + @Path("/btc/walletbalance") + @Operation( + summary = "Returns BTC balance for BIP32 wallet", + description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private key in base58", + example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) + public String getBitcoinWalletBalance(String xprv58) { + Security.checkApiCallAllowed(request); + + if (!BTC.getInstance().isValidXprv(xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = BTC.getInstance().getWalletBalance(xprv58); + if (balance == null) + return "null"; + + return balance.toString(); + } + @GET @Path("/tradebot") @Operation( diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 48bb51c6..0f2920a7 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -206,10 +206,7 @@ public class BTC { */ public Transaction buildSpend(String xprv58, String recipient, long amount) { Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); - wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); - - DeterministicKeyChain activeKeyChain = wallet.getActiveKeyChain(); - activeKeyChain.setLookaheadSize(3); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT)); Address destination = Address.fromString(this.params, recipient); SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); @@ -218,111 +215,144 @@ public class BTC { // Much smaller fee for TestNet3 sendRequest.feePerKb = Coin.valueOf(2000L); - do { - activeKeyChain.maybeLookAhead(); + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } - try { - wallet.completeTx(sendRequest); - break; - } catch (InsufficientMoneyException e) { - return null; - } catch (WalletAwareUTXOProvider.AllKeysSpentException e) { - // loop again and use maybeLookAhead() to generate more keys to check - } - } while (true); + /** + * Returns unspent Bitcoin balance given 'm' BIP32 key. + * + * @param xprv58 BIP32 extended Bitcoin private key + * @return unspent BTC balance, or null if unable to determine balance + */ + public Long getWalletBalance(String xprv58) { + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT)); - return sendRequest.tx; + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; } // UTXOProvider support static class WalletAwareUTXOProvider implements UTXOProvider { - private final Wallet wallet; + private static final int LOOKAHEAD_INCREMENT = 3; + private final BTC btc; + private final Wallet wallet; - // We extend RuntimeException for unchecked-ness so it will bubble up to caller. - // We can't use UTXOProviderException as it will be wrapped in RuntimeException anyway. - @SuppressWarnings("serial") - public static class AllKeysSpentException extends RuntimeException { - public AllKeysSpentException() { - super(); - } + enum KeySearchMode { + REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT; } + private final KeySearchMode keySearchMode; + private final DeterministicKeyChain keyChain; - public WalletAwareUTXOProvider(BTC btc, Wallet wallet) { + public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) { this.btc = btc; this.wallet = wallet; + this.keySearchMode = keySearchMode; + this.keyChain = this.wallet.getActiveKeyChain(); + + // Set up wallet's key chain + this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT); + this.keyChain.maybeLookAhead(); } public List getOpenTransactionOutputs(List keys) throws UTXOProviderException { List allUnspentOutputs = new ArrayList<>(); final boolean coinbase = false; - boolean areAllKeysSpent = true; - for (ECKey key : keys) { - if (btc.spentKeys.contains(key)) { - wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); - continue; - } + int ki = 0; + do { + boolean areAllKeysUnspent = true; + boolean areAllKeysSpent = true; - Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); - byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + for (; ki < keys.size(); ++ki) { + ECKey key = keys.get(ki); - List unspentOutputs = btc.electrumX.getUnspentOutputs(script); - if (unspentOutputs == null) - throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); - - /* - * If there are no unspent outputs then either: - * a) all the outputs have been spent - * b) address has never been used - * - * For case (a) we want to remember not to check this address (key) again. - * If all passed keys are spent then we need to signal caller that they might want to - * generate more keys to check. - */ - - if (unspentOutputs.isEmpty()) { - // Ask for transaction history - if it's empty then key has never been used - List historicTransactionHashes = btc.electrumX.getAddressTransactions(script); - if (historicTransactionHashes == null) - throw new UTXOProviderException( - String.format("Unable to fetch transaction history for %s", address)); - - if (!historicTransactionHashes.isEmpty()) { - // Fully spent key - case (a) - btc.spentKeys.add(key); + if (btc.spentKeys.contains(key)) { wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); - } else { - // Key never been used - case (b) - areAllKeysSpent = false; + areAllKeysUnspent = false; + continue; } - continue; + Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List unspentOutputs = btc.electrumX.getUnspentOutputs(script); + if (unspentOutputs == null) + throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); + + /* + * If there are no unspent outputs then either: + * a) all the outputs have been spent + * b) address has never been used + * + * For case (a) we want to remember not to check this address (key) again. + */ + + if (unspentOutputs.isEmpty()) { + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = btc.electrumX.getAddressTransactions(script); + if (historicTransactionHashes == null) + throw new UTXOProviderException( + String.format("Unable to fetch transaction history for %s", address)); + + if (!historicTransactionHashes.isEmpty()) { + // Fully spent key - case (a) + btc.spentKeys.add(key); + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + areAllKeysUnspent = false; + } else { + // Key never been used - case (b) + areAllKeysSpent = false; + } + + continue; + } + + // If we reach here, then there's definitely at least one unspent key + areAllKeysSpent = false; + + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = btc.getOutputs(unspentOutput.hash); + if (transactionOutputs == null) + throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s", + HashCode.fromBytes(unspentOutput.hash))); + + TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index); + + UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index, + Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase, + transactionOutput.getScriptPubKey()); + + allUnspentOutputs.add(utxo); + } } - // If we reach here, then there's definitely at least one unspent key - areAllKeysSpent = false; + if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent) + || (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) { + // Generate some more keys + this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT); + this.keyChain.maybeLookAhead(); - for (UnspentOutput unspentOutput : unspentOutputs) { - List transactionOutputs = btc.getOutputs(unspentOutput.hash); - if (transactionOutputs == null) - throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s", - HashCode.fromBytes(unspentOutput.hash))); - - TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index); - - UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index, - Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase, - transactionOutput.getScriptPubKey()); - - allUnspentOutputs.add(utxo); + // This returns all keys, including those already in 'keys' + List allLeafKeys = this.keyChain.getLeafKeys(); + // Add only new keys onto our list of keys to search + List newKeys = allLeafKeys.subList(ki, allLeafKeys.size()); + keys.addAll(newKeys); + // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again } - } - if (areAllKeysSpent) - // Notify caller that they need to check more keys - throw new AllKeysSpentException(); + // If we have processed all keys, then we're done + } while (ki < keys.size()); return allUnspentOutputs; } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index 1b6123a7..f5829be8 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -73,4 +73,17 @@ public class BtcTests extends Common { btc.buildSpend(xprv58, recipient, amount); } + @Test + public void testGetWalletBalance() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = btc.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(BTC.format(balance)); + } + }