diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java index 541cb8c1..f2485389 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java @@ -22,6 +22,9 @@ public class CrossChainBitcoinRefundRequest { @Schema(description = "Bitcoin miner fee", example = "0.00001000") public BigDecimal bitcoinMinerFee; + @Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf") + public byte[] receivingAccountInfo; + public CrossChainBitcoinRefundRequest() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 36db35ad..5828b2a2 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -719,6 +719,13 @@ public class CrossChainResource { if (refundRequest.atAddress == null || !Crypto.isValidAtAddress(refundRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + if (refundRequest.receivingAccountInfo == null) + refundRequest.receivingAccountInfo = refundKey.getPubKeyHash(); + + if (refundRequest.receivingAccountInfo.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress); @@ -756,7 +763,7 @@ public class CrossChainResource { Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); + org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundRequest.receivingAccountInfo); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); if (!wasBroadcast) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index ec7209c9..f35b1312 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -980,7 +980,17 @@ public class TradeBot { ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB); + + // Determine receive address for refund + String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); + if (receiveAddress == null) { + LOGGER.debug(() -> String.format("Couldn't determine a receive address for P2SH-B refund?")); + return; + } + + Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB, receiving.getHash()); if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { // We couldn't refund P2SH-B at this time LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?")); @@ -1025,7 +1035,16 @@ public class TradeBot { return; } - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + // Determine receive address for refund + String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); + if (receiveAddress == null) { + LOGGER.debug(() -> String.format("Couldn't determine a receive address for P2SH-A refund?")); + return; + } + + Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA(), receiving.getHash()); if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { // We couldn't refund P2SH-A at this time LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A refund transaction?")); diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 5e5a3639..99c06b93 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -20,6 +20,7 @@ import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.core.UTXO; import org.bitcoinj.core.UTXOProvider; import org.bitcoinj.core.UTXOProviderException; +import org.bitcoinj.crypto.ChildNumber; import org.bitcoinj.crypto.DeterministicHierarchy; import org.bitcoinj.crypto.DeterministicKey; import org.bitcoinj.params.MainNetParams; @@ -240,6 +241,89 @@ public class BTC { return balance.value; } + /** + * Returns first unused receive address given 'm' BIP32 key. + * + * @param xprv58 BIP32 extended Bitcoin private key + * @return Bitcoin P2PKH address, or null if something went wrong + */ + public String getUnusedReceiveAddress(String xprv58) { + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + final int keyChainPathSize = keyChain.getAccountPath().size(); + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + int ki = 0; + do { + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + List dKeyPath = dKey.getPath(); + + // If keyChain is based on 'm', then make sure dKey is m/0/ki + if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO) + continue; + + // Check unspent + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List unspentOutputs = this.electrumX.getUnspentOutputs(script); + if (unspentOutputs == null) + return null; + + /* + * 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()) { + // If this is a known key that has been spent before, then we can skip asking for transaction history + if (this.spentKeys.contains(dKey)) { + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) dKey); + continue; + } + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.electrumX.getAddressTransactions(script); + if (historicTransactionHashes == null) + return null; + + if (!historicTransactionHashes.isEmpty()) { + // Fully spent key - case (a) + this.spentKeys.add(dKey); + wallet.getActiveKeyChain().markKeyAsUsed(dKey); + } else { + // Key never been used - case (b) + return address.toString(); + } + } + + // Key has unspent outputs, hence used, so no good to us + this.spentKeys.remove(dKey); + } + + // Generate some more keys + keyChain.setLookaheadSize(keyChain.getLookaheadSize() + WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + // This returns all keys, including those already in 'keys' + List allLeafKeys = 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 + + // Process new keys + } while (true); + } + // UTXOProvider support static class WalletAwareUTXOProvider implements UTXOProvider { @@ -320,6 +404,7 @@ public class BTC { } // If we reach here, then there's definitely at least one unspent key + btc.spentKeys.remove(key); areAllKeysSpent = false; for (UnspentOutput unspentOutput : unspentOutputs) { diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java index 8a0fa546..4bd175f2 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -131,9 +131,10 @@ public class BTCP2SH { * @param fundingOutput output from transaction that funded P2SH address * @param redeemScriptBytes the redeemScript itself, in byte[] form * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript + * @param receivingAccountInfo Bitcoin PKH used for output * @return Signed Bitcoin transaction for refunding P2SH */ - public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime) { + public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) { Function refundSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... ScriptBuilder scriptBuilder = new ScriptBuilder(); @@ -152,7 +153,7 @@ public class BTCP2SH { }; // Send funds back to funding address - return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash()); + return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo); } /** diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index a00e54f6..6e56992e 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -104,4 +104,17 @@ public class BtcTests extends Common { assertEquals(balance, repeatBalance); } + @Test + public void testGetUnusedReceiveAddress() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = btc.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index c6fd88ed..6222bb83 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -186,7 +186,7 @@ public class Refund { Coin refundAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(refundAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); + Transaction redeemTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundKey.getPubKeyHash()); byte[] redeemBytes = redeemTransaction.bitcoinSerialize();