diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index d1cbcd8f..6db79ceb 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1029,8 +1029,16 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); - if (tradeBotData.getState() != TradeBotData.State.ALICE_DONE && tradeBotData.getState() != TradeBotData.State.BOB_DONE) - return "false"; + switch (tradeBotData.getState()) { + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + + default: + return "false"; + } repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); repository.saveChanges(); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 61dbb39c..0402fe0a 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -20,6 +20,7 @@ import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountBalanceData; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; @@ -104,6 +105,8 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress)); + // Return to user for signing and broadcast as we don't have their Qortal private key try { return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); @@ -124,7 +127,7 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); - // We need to generate lockTimeA: halfway of refundTimeout from now + // We need to generate lockTime-A: halfway of refundTimeout from now int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, @@ -134,30 +137,32 @@ public class TradeBot { tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA); - // Check we have enough funds via xprv58 to fund both P2SH to cover expectedBitcoin + // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); - long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-a */ + FEE_AMOUNT /* P2SH-b */; + long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-A */ + FEE_AMOUNT /* P2SH-B */; Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) return false; - // P2SH_a to be funded + // P2SH-A to be funded byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - // Fund P2SH-a + // Fund P2SH-A Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT); if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { - // We couldn't fund P2SH-a at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a funding transaction?")); + // We couldn't fund P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?")); return false; } repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + return true; } @@ -230,6 +235,18 @@ public class TradeBot { case BOB_DONE: break; + case ALICE_REFUNDING_B: + handleAliceRefundingP2shB(repository, tradeBotData); + break; + + case ALICE_REFUNDING_A: + handleAliceRefundingP2shA(repository, tradeBotData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + default: LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); } @@ -248,12 +265,14 @@ public class TradeBot { tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -261,14 +280,28 @@ public class TradeBot { byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < crossChainTradeData.expectedBitcoin) { - if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-a balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + // If AT has finished then maybe Bob cancelled his trade offer + if (atData.getIsFinished()) { + // No point sending MESSAGE - might as well wait for refund + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); return; } + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < crossChainTradeData.expectedBitcoin) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-A balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + + return; + } + + // P2SH-A funding confirmed + // Attempt to send MESSAGE to Bob's Qortal trade address byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); @@ -283,20 +316,34 @@ public class TradeBot { ValidationResult result = messageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", + p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); } private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + return; } @@ -320,7 +367,7 @@ public class TradeBot { if (messageTransactionData.isText()) continue; - // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash + // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A byte[] messageData = messageTransactionData.getData(); BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); if (offerMessageData == null) @@ -356,13 +403,19 @@ public class TradeBot { ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", outgoingMessageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", outgoingMessageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress)); + return; } @@ -374,15 +427,30 @@ public class TradeBot { } private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A + if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA()) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + if (atData.getIsFinished()) + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); + else + LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress)); + + return; + } + // We're waiting for AT to be in TRADE mode if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE) return; @@ -390,12 +458,17 @@ public class TradeBot { // We're expecting AT to be locked to our native trade address if (!crossChainTradeData.qortalRecipient.equals(tradeBotData.getTradeNativeAddress())) { // AT locked to different address! We shouldn't continue but wait and refund. - LOGGER.warn(() -> String.format("Trade AT '%s' locked to '%s', not us ('%s')", + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalRecipient, - tradeBotData.getTradeNativeAddress())); + tradeBotData.getTradeNativeAddress(), + p2shAddress)); - // There's no P2SH-b at this point, so jump straight to refunding P2SH-a + // There's no P2SH-B at this point, so jump straight to refunding P2SH-A tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -403,12 +476,12 @@ public class TradeBot { return; } - // Alice needs to fund P2SH-b here + // Alice needs to fund P2SH-B here // Find our MESSAGE to AT from previous state List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null); if (messageTransactionsData == null) { - LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT '%s' from repository", crossChainTradeData.qortalCreatorTradeAddress)); + LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT %s from repository", crossChainTradeData.qortalCreatorTradeAddress)); return; } @@ -421,16 +494,16 @@ public class TradeBot { } if (recipientMessageTimestamp == null) { - LOGGER.warn(() -> String.format("Unable to find our message to trade creator '%s'?", crossChainTradeData.qortalCreatorTradeAddress)); + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); return; } int lockTimeA = tradeBotData.getLockTimeA(); int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); - // Our calculated lockTimeB should match AT's calculated lockTimeB + // Our calculated lockTime-B should match AT's calculated lockTime-B if (lockTimeB != crossChainTradeData.lockTimeB) { - LOGGER.debug(() -> String.format("Trade AT lockTimeB '%d' doesn't match our lockTimeB '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + LOGGER.debug(() -> String.format("Trade AT lockTime-B '%d' doesn't match our lockTime-B '%d'", crossChainTradeData.lockTimeB, lockTimeB)); // We'll eventually refund return; } @@ -440,27 +513,39 @@ public class TradeBot { Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT); if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { - // We couldn't fund P2SH-b at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b funding transaction?")); + // We couldn't fund P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?")); return; } - // P2SH-b funded, now we wait for Bob to redeem it + // P2SH-B funded, now we wait for Bob to redeem it tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", + tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); } private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + // If we've passed AT refund timestamp then AT will have finished after auto-refunding + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + // It's possible AT hasn't processed our previous MESSAGE yet and so lockTimeB won't be set if (crossChainTradeData.lockTimeB == null) // AT yet to process MESSAGE @@ -472,36 +557,36 @@ public class TradeBot { Long balance = BTC.getInstance().getBalance(p2shAddress); if (balance == null || balance < FEE_AMOUNT) { if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-b balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); + LOGGER.debug(() -> String.format("P2SH-B balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); return; } - // Redeem P2SH-b using secret-b - Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-a + // Redeem P2SH-B using secret-B + Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret()); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-b at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b redeeming transaction?")); + // We couldn't redeem P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B redeeming transaction?")); return; } - // P2SH-b redeemed, now we wait for Alice to use secret to redeem AT + // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); } private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -509,9 +594,20 @@ public class TradeBot { byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + // Refund P2SH-B if we've passed lockTime-B + if (NTP.getTime() >= crossChainTradeData.lockTimeB) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress)); + + return; + } + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); if (p2shTransactions == null) { - LOGGER.debug(() -> String.format("Unable to fetch transactions relating to '%s'", p2shAddress)); + LOGGER.debug(() -> String.format("Unable to fetch transactions relating to %s", p2shAddress)); return; } @@ -530,26 +626,29 @@ public class TradeBot { messageTransaction.computeNonce(); messageTransaction.sign(sender); - // reset repository state to prevent deadlock + // Reset repository state to prevent deadlock repository.discardChanges(); ValidationResult result = messageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.ALICE_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + String receiveAddress = tradeBotData.getTradeNativeAddress(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", + p2shAddress, tradeBotData.getAtAddress(), receiveAddress)); } private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -559,13 +658,25 @@ public class TradeBot { // Not finished yet return; - byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); - if (secretA == null) { - LOGGER.debug(() -> String.format("Unable to find secret-a from redeem message to AT '%s'?", tradeBotData.getAtAddress())); + // If AT's balance is zero, then it's auto-refunded so we're done + AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT); + if (atBalanceData == null || atBalanceData.getBalance() == 0L) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + return; } - // Use secretA to redeem P2SH-a + byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); @@ -577,14 +688,86 @@ public class TradeBot { Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-a at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a redeeming transaction?")); + // We couldn't redeem P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A redeeming transaction?")); return; } tradeBotData.setState(TradeBotData.State.BOB_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + String receiveAddress = BTC.getInstance().pkhToAddress(tradeBotData.getTradeForeignPublicKeyHash()); + + LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receiveAddress)); + } + + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-B until lockTime-B has passed + if (NTP.getTime() <= crossChainTradeData.lockTimeB) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.ZERO; + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + 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?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); + } + + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= tradeBotData.getLockTimeA()) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + 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?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED); + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress)); } } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 6b350349..66603d69 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -117,6 +117,7 @@ public class BTC { return format(Coin.valueOf(amount)); } + /** Returns P2PKH Bitcoin address using passed public key hash. */ public String pkhToAddress(byte[] publicKeyHash) { return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString(); } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 8a77d80f..2e935ee5 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -18,8 +18,8 @@ public class TradeBotData { private byte[] tradePrivateKey; public enum State { - BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), - ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_REFUNDING_B(95), ALICE_REFUNDING_A(100), ALICE_DONE(105); + BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), BOB_REFUNDED(35), + ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_DONE(95), ALICE_REFUNDING_B(100), ALICE_REFUNDING_A(105), ALICE_REFUNDED(110); public final int value; private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));