diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 99820022..f0b5d0d1 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -14,7 +14,7 @@ public class CrossChainSecretRequest { @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") + @Schema(description = "secret-A + secret-B (64 bytes)", example = "2gt2nSVBFknLfdU5buKtScLuTibkt9C3x6PZVqnA3AJ6BdEf3A9RbSj5Hn5QkvavdTTfmttNEaYEVw34TZdz135Q") public byte[] secret; public CrossChainSecretRequest() { diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java new file mode 100644 index 00000000..2c319fd9 --- /dev/null +++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java @@ -0,0 +1,20 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotRespondRequest { + + @Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy") + public String atAddress; + + @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") + public String xprv58; + + public TradeBotRespondRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 92cf4096..d1cbcd8f 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -21,7 +21,6 @@ import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; -import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -36,12 +35,13 @@ import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.model.TradeBotRespondRequest; import org.qortal.api.model.CrossChainBitcoinP2SHStatus; import org.qortal.api.model.CrossChainBitcoinRedeemRequest; import org.qortal.api.model.CrossChainBitcoinRefundRequest; @@ -55,6 +55,7 @@ import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.crosschain.CrossChainTradeData.Mode; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; @@ -123,8 +124,6 @@ public class CrossChainResource { } return crossChainTradesData; - } catch (ApiException e) { - throw e; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -152,6 +151,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) public String buildTrade(CrossChainBuildRequest tradeRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -245,6 +246,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -277,8 +280,8 @@ public class CrossChainResource { @POST @Path("/tradeoffer/secret") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient", - description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.
" + summary = "Builds raw, unsigned MESSAGE transaction that sends secrets to AT, releasing funds to recipient", + description = "Specify address of cross-chain AT that needs to be messaged, and both 32-byte secrets.
" + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( @@ -302,6 +305,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendSecret(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + byte[] recipientPublicKey = secretRequest.recipientPublicKey; if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -310,7 +315,7 @@ public class CrossChainResource { if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH) + if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH * 2) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); try (final Repository repository = RepositoryManager.getRepository()) { @@ -365,6 +370,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -415,6 +422,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -439,6 +448,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -494,6 +505,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -518,6 +531,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -607,6 +622,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { + Security.checkApiCallAllowed(request); + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -632,6 +649,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { + Security.checkApiCallAllowed(request); + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -716,6 +735,7 @@ public class CrossChainResource { @Path("/p2sh/a/redeem") @Operation( summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address", + description = "Secret payload needs to be secret-A (64 bytes)", requestBody = @RequestBody( required = true, content = @Content( @@ -734,6 +754,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { + Security.checkApiCallAllowed(request); + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -741,6 +763,7 @@ public class CrossChainResource { @Path("/p2sh/b/redeem") @Operation( summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address", + description = "Secret payload needs to be secret-B (32 bytes)", requestBody = @RequestBody( required = true, content = @Content( @@ -759,6 +782,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { + Security.checkApiCallAllowed(request); + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -845,8 +870,35 @@ public class CrossChainResource { } } - @POST + @GET @Path("/tradebot") + @Operation( + summary = "List current trade-bot states", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = TradeBotData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getTradeBotStates() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/tradebot/create") @Operation( summary = "Create a trade offer", requestBody = @RequestBody( @@ -866,6 +918,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + Security.checkApiCallAllowed(request); + if (tradeBotCreateRequest.tradeTimeout < 600) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -884,9 +938,19 @@ public class CrossChainResource { } @POST - @Path("/tradebot/{ataddress}") + @Path("/tradebot/respond") @Operation( - summary = "Respond to a trade offer", + summary = "Respond to a trade offer (WILL SPEND BITCOIN!)", + description = "Start a new trade-bot entry to respond to chosen trade offer. Trade-bot starts by funding Bitcoin side of trade!", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotRespondRequest.class + ) + ) + ), responses = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) @@ -894,10 +958,24 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String tradeBotResponder(@PathParam("ataddress") String atAddress) { + public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + final String atAddress = tradeBotRespondRequest.atAddress; + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + final byte[] xprv; + try { + xprv = Base58.decode(tradeBotRespondRequest.xprv58); + + if (xprv.length != 4 + 1 + 4 + 4 + 32 + 33 + 4) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } + // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, null, atAddress); // null to skip creator check @@ -906,11 +984,58 @@ public class CrossChainResource { if (crossChainTradeData.mode != Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - String p2shAddress = TradeBot.startResponse(repository, crossChainTradeData); - if (p2shAddress == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58); + + return result ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/tradebot/trade") + @Operation( + summary = "Delete completed trade", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "Au6kioR6XT2CPxT6qsyQ1WjS9zNYg7tpwSrFeVqCDdMR" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String tradeBotDelete(String tradePrivateKey58) { + Security.checkApiCallAllowed(request); + + final byte[] tradePrivateKey; + try { + tradePrivateKey = Base58.decode(tradePrivateKey58); + + if (tradePrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } + + 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"; + + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + repository.saveChanges(); - return p2shAddress; + return "true"; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 18ada4b8..61dbb39c 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -8,7 +8,10 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; @@ -38,7 +41,8 @@ public class TradeBot { private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); private static final Random RANDOM = new SecureRandom(); - + private static final long FEE_AMOUNT = 1000L; + private static TradeBot instance; /** To help ensure only TradeBot is only active on one thread. */ @@ -61,7 +65,7 @@ public class TradeBot { byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); - String tradeAddress = Crypto.toAddress(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); @@ -79,7 +83,7 @@ public class TradeBot { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -93,9 +97,10 @@ public class TradeBot { TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, atAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null); + tradeBotCreateRequest.bitcoinAmount, null, null, null); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -107,13 +112,14 @@ public class TradeBot { } } - public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretA = generateSecret(); byte[] hashOfSecretA = Crypto.hash160(secretA); byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); @@ -123,15 +129,36 @@ public class TradeBot { TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, crossChainTradeData.qortalAtAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secretA, hashOfSecretA, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedBitcoin, null, lockTimeA); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA); + + // Check we have enough funds via xprv58 to fund both P2SH to cover expectedBitcoin + String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); + + 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 byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); - return BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + // 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?")); + return false; + } + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + return true; } private static byte[] generateTradePrivateKey() { @@ -175,12 +202,32 @@ public class TradeBot { handleBobWaitingForAtConfirm(repository, tradeBotData); break; + case ALICE_WAITING_FOR_P2SH_A: + handleAliceWaitingForP2shA(repository, tradeBotData); + break; + case BOB_WAITING_FOR_MESSAGE: handleBobWaitingForMessage(repository, tradeBotData); break; - case ALICE_WAITING_FOR_P2SH_A: - handleAliceWaitingForP2shA(repository, tradeBotData); + case ALICE_WAITING_FOR_AT_LOCK: + handleAliceWaitingForAtLock(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_P2SH_B: + handleBobWaitingForP2shB(repository, tradeBotData); + break; + + case ALICE_WATCH_P2SH_B: + handleAliceWatchingP2shB(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + handleBobWaitingForAtRedeem(repository, tradeBotData); + break; + + case ALICE_DONE: + case BOB_DONE: break; default: @@ -203,6 +250,48 @@ public class TradeBot { repository.saveChanges(); } + 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())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + 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))); + + return; + } + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // 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())); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + 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()); @@ -211,7 +300,7 @@ public class TradeBot { return; } - String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); + String address = tradeBotData.getTradeNativeAddress(); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); @@ -231,8 +320,6 @@ public class TradeBot { if (messageTransactionData.isText()) continue; - // Could enforce encryption here - // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash byte[] messageData = messageTransactionData.getData(); BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); @@ -286,7 +373,9 @@ public class TradeBot { } } - private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -294,18 +383,149 @@ public class TradeBot { } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE) + return; + + // 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')", + tradeBotData.getAtAddress(), + crossChainTradeData.qortalRecipient, + tradeBotData.getTradeNativeAddress())); + + // 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(); + + return; + } + + // 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)); + return; + } + + // Find our message + Long recipientMessageTimestamp = null; + for (MessageTransactionData messageTransactionData : messageTransactionsData) + if (Arrays.equals(messageTransactionData.getSenderPublicKey(), tradeBotData.getTradeNativePublicKey())) { + recipientMessageTimestamp = messageTransactionData.getTimestamp(); + break; + } + + if (recipientMessageTimestamp == null) { + 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 + if (lockTimeB != crossChainTradeData.lockTimeB) { + LOGGER.debug(() -> String.format("Trade AT lockTimeB '%d' doesn't match our lockTimeB '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + // We'll eventually refund + return; + } + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + 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?")); + return; + } + + // 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(); + } + + 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())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // 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 + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < crossChainTradeData.expectedBitcoin) + 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))); + return; + } - // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + // 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?")); + return; + } + + // P2SH-b redeemed, now we wait for Alice to use secret to redeem AT + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + + 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())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); + if (p2shTransactions == null) { + LOGGER.debug(() -> String.format("Unable to fetch transactions relating to '%s'", p2shAddress)); + return; + } + + byte[] secretB = BTCP2SH.findP2shSecret(p2shAddress, p2shTransactions); + if (secretB == null) + // Secret not revealed at this time + return; + + // Send MESSAGE to AT using both secrets + byte[] secretA = tradeBotData.getSecret(); + byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); messageTransaction.computeNonce(); messageTransaction.sign(sender); @@ -319,7 +539,50 @@ public class TradeBot { return; } - tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + tradeBotData.setState(TradeBotData.State.ALICE_DONE); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + + 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())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should be 'finished' once Alice has redeemed QORT funds + if (!atData.getIsFinished()) + // 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())); + return; + } + + // Use secretA to redeem P2SH-a + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + 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?")); + return; + } + + tradeBotData.setState(TradeBotData.State.BOB_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 6bf00073..6b350349 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -1,26 +1,42 @@ package org.qortal.crosschain; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.UTXO; +import org.bitcoinj.core.UTXOProvider; +import org.bitcoinj.core.UTXOProviderException; +import org.bitcoinj.crypto.DeterministicHierarchy; +import org.bitcoinj.crypto.DeterministicKey; import org.bitcoinj.params.MainNetParams; import org.bitcoinj.params.RegTestParams; import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.Script.ScriptType; import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.utils.MonetaryFormat; +import org.bitcoinj.wallet.DeterministicKeyChain; +import org.bitcoinj.wallet.SendRequest; +import org.bitcoinj.wallet.Wallet; +import org.qortal.crosschain.ElectrumX.UnspentOutput; import org.qortal.crypto.Crypto; import org.qortal.settings.Settings; import org.qortal.utils.BitTwiddling; -import org.qortal.utils.Pair; + +import com.google.common.hash.HashCode; public class BTC { @@ -60,6 +76,9 @@ public class BTC { private final NetworkParameters params; private final ElectrumX electrumX; + // Let ECKey.equals() do the hard work + private final Set spentKeys = new HashSet<>(); + // Constructors and instance private BTC() { @@ -121,9 +140,10 @@ public class BTC { List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); - // Descending, but order shouldn't matter as we're picking median... + // Descending order blockTimestamps.sort((a, b) -> Integer.compare(b, a)); + // Pick median return blockTimestamps.get(5); } @@ -132,17 +152,17 @@ public class BTC { } public List getUnspentOutputs(String base58Address) { - List> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); + List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); if (unspentOutputs == null) return null; List unspentTransactionOutputs = new ArrayList<>(); - for (Pair unspentOutput : unspentOutputs) { - List transactionOutputs = getOutputs(unspentOutput.getA()); + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = getOutputs(unspentOutput.hash); if (transactionOutputs == null) return null; - unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB())); + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); } return unspentTransactionOutputs; @@ -157,6 +177,7 @@ public class BTC { return transaction.getOutputs(); } + /** Returns list of raw transactions spending passed address. */ public List getAddressTransactions(String base58Address) { return this.electrumX.getAddressTransactions(addressToScript(base58Address)); } @@ -165,6 +186,147 @@ public class BTC { return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); } + /** + * Returns bitcoinj transaction sending amount to recipient. + * + * @param xprv58 BIP32 extended Bitcoin private key + * @param recipient P2PKH address + * @param amount unscaled amount + * @return transaction, or null if insufficient funds + */ + 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); + + Address destination = Address.fromString(this.params, recipient); + SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); + + if (this.params == TestNet3Params.get()) + // Much smaller fee for TestNet3 + sendRequest.feePerKb = Coin.valueOf(2000L); + + do { + activeKeyChain.maybeLookAhead(); + + 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); + + return sendRequest.tx; + } + + // UTXOProvider support + + static class WalletAwareUTXOProvider implements UTXOProvider { + private final Wallet wallet; + private final BTC btc; + + // 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(); + } + } + + public WalletAwareUTXOProvider(BTC btc, Wallet wallet) { + this.btc = btc; + this.wallet = wallet; + } + + 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; + } + + 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 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); + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + } + + 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 (areAllKeysSpent) + // Notify caller that they need to check more keys + throw new AllKeysSpentException(); + + return allUnspentOutputs; + } + + public int getChainHeadHeight() throws UTXOProviderException { + Integer height = btc.electrumX.getCurrentHeight(); + if (height == null) + throw new UTXOProviderException("Unable to determine Bitcoin chain height"); + + return height.intValue(); + } + + public NetworkParameters getParams() { + return btc.params; + } + } + // Utility methods for us private byte[] addressToScript(String base58Address) { diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index ad185d87..3910bfa4 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -4,6 +4,7 @@ import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; +import java.util.List; import org.ciyam.at.API; import org.ciyam.at.CompilationException; @@ -19,6 +20,7 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Base58; @@ -747,4 +749,49 @@ public class BTCACCT { return (int) ((lockTimeA + (recipientMessageTimestamp / 1000L)) / 2L); } + public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalRecipient; + + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find redeem message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != 32 + 32) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract both secretA & secretB + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + byte[] secretB = new byte[32]; + System.arraycopy(messageData, 32, secretB, 0, secretB.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + byte[] hashOfSecretB = Crypto.hash160(secretB); + if (!Arrays.equals(hashOfSecretB, crossChainTradeData.hashOfSecretB)) + continue; + + return secretA; + } + + return null; + } + } diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 41c3d99d..0c5213f5 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -25,7 +25,6 @@ import org.json.simple.JSONObject; import org.json.simple.JSONValue; import org.qortal.crypto.Crypto; import org.qortal.crypto.TrustlessSSLSocketFactory; -import org.qortal.utils.Pair; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; @@ -166,7 +165,21 @@ public class ElectrumX { return (Long) balanceJson.get("confirmed"); } - public List> getUnspentOutputs(byte[] script) { + public static class UnspentOutput { + public final byte[] hash; + public final int index; + public final int height; + public final long value; + + public UnspentOutput(byte[] hash, int index, int height, long value) { + this.hash = hash; + this.index = index; + this.height = height; + this.value = value; + } + } + + public List getUnspentOutputs(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); @@ -174,14 +187,16 @@ public class ElectrumX { if (unspentJson == null) return null; - List> unspentOutputs = new ArrayList<>(); + List unspentOutputs = new ArrayList<>(); for (Object rawUnspent : unspentJson) { JSONObject unspent = (JSONObject) rawUnspent; byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); int outputIndex = ((Long) unspent.get("tx_pos")).intValue(); + int height = ((Long) unspent.get("height")).intValue(); + long value = (Long) unspent.get("value"); - unspentOutputs.add(new Pair<>(txHash, outputIndex)); + unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); } return unspentOutputs; @@ -195,6 +210,7 @@ public class ElectrumX { return HashCode.fromString(rawTransactionHex).asBytes(); } + /** Returns list of raw transactions. */ public List getAddressTransactions(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 4441212c..8a77d80f 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -15,14 +15,11 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class TradeBotData { - // Never expose this - @XmlTransient - @Schema(hidden = true) private byte[] tradePrivateKey; public enum State { - BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), - ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(90), ALICE_WATCH_P2SH_B(100); + 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); public final int value; private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); @@ -41,6 +38,7 @@ public class TradeBotData { private byte[] tradeNativePublicKey; private byte[] tradeNativePublicKeyHash; + String tradeNativeAddress; private byte[] secret; private byte[] hashOfSecret; @@ -50,24 +48,36 @@ public class TradeBotData { private long bitcoinAmount; + // Never expose this + @XmlTransient + @Schema(hidden = true) + private String xprv58; + private byte[] lastTransactionSignature; private Integer lockTimeA; + protected TradeBotData() { + /* JAXB */ + } + public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, - byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] hashOfSecret, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, + byte[] secret, byte[] hashOfSecret, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, byte[] lastTransactionSignature, Integer lockTimeA) { + long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; this.tradeNativePublicKey = tradeNativePublicKey; this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; + this.tradeNativeAddress = tradeNativeAddress; this.secret = secret; this.hashOfSecret = hashOfSecret; this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; this.bitcoinAmount = bitcoinAmount; + this.xprv58 = xprv58; this.lastTransactionSignature = lastTransactionSignature; this.lockTimeA = lockTimeA; } @@ -100,6 +110,10 @@ public class TradeBotData { return this.tradeNativePublicKeyHash; } + public String getTradeNativeAddress() { + return this.tradeNativeAddress; + } + public byte[] getSecret() { return this.secret; } @@ -120,6 +134,10 @@ public class TradeBotData { return this.bitcoinAmount; } + public String getXprv58() { + return this.xprv58; + } + public byte[] getLastTransactionSignature() { return this.lastTransactionSignature; } diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java index e1b409a0..cee1dc69 100644 --- a/src/main/java/org/qortal/repository/CrossChainRepository.java +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -6,8 +6,13 @@ import org.qortal.data.crosschain.TradeBotData; public interface CrossChainRepository { + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException; + public List getAllTradeBotData() throws DataException; public void save(TradeBotData tradeBotData) throws DataException; + /** Delete trade-bot states using passed private key. */ + public int delete(byte[] tradePrivateKey) throws DataException; + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 392f42b1..3c30444e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -17,13 +17,58 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { this.repository = repository; } + @Override + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { + String sql = "SELECT trade_state, at_address, " + + "trade_native_public_key, trade_native_public_key_hash, " + + "trade_native_address, secret, hash_of_secret, " + + "trade_foreign_public_key, trade_foreign_public_key_hash, " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a " + + "FROM TradeBotStates " + + "WHERE trade_private_key = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, tradePrivateKey)) { + if (resultSet == null) + return null; + + int tradeStateValue = resultSet.getInt(1); + TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); + if (tradeState == null) + throw new DataException("Illegal trade-bot trade-state fetched from repository"); + + String atAddress = resultSet.getString(2); + byte[] tradeNativePublicKey = resultSet.getBytes(3); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(4); + String tradeNativeAddress = resultSet.getString(5); + byte[] secret = resultSet.getBytes(6); + byte[] hashOfSecret = resultSet.getBytes(7); + byte[] tradeForeignPublicKey = resultSet.getBytes(8); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); + long bitcoinAmount = resultSet.getLong(10); + String xprv58 = resultSet.getString(11); + byte[] lastTransactionSignature = resultSet.getBytes(12); + Integer lockTimeA = resultSet.getInt(13); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + + return new TradeBotData(tradePrivateKey, tradeState, + atAddress, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading state from repository", e); + } + } + @Override public List getAllTradeBotData() throws DataException { String sql = "SELECT trade_private_key, trade_state, at_address, " + "trade_native_public_key, trade_native_public_key_hash, " - + "secret, hash_of_secret, " + + "trade_native_address, secret, hash_of_secret, " + "trade_foreign_public_key, trade_foreign_public_key_hash, " - + "bitcoin_amount, last_transaction_signature, locktime_a " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -42,21 +87,24 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { String atAddress = resultSet.getString(3); byte[] tradeNativePublicKey = resultSet.getBytes(4); byte[] tradeNativePublicKeyHash = resultSet.getBytes(5); - byte[] secret = resultSet.getBytes(6); - byte[] hashOfSecret = resultSet.getBytes(7); - byte[] tradeForeignPublicKey = resultSet.getBytes(8); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); - long bitcoinAmount = resultSet.getLong(10); - byte[] lastTransactionSignature = resultSet.getBytes(11); - Integer lockTimeA = resultSet.getInt(12); + String tradeNativeAddress = resultSet.getString(6); + byte[] secret = resultSet.getBytes(7); + byte[] hashOfSecret = resultSet.getBytes(8); + byte[] tradeForeignPublicKey = resultSet.getBytes(9); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10); + long bitcoinAmount = resultSet.getLong(11); + String xprv58 = resultSet.getString(12); + byte[] lastTransactionSignature = resultSet.getBytes(13); + Integer lockTimeA = resultSet.getInt(14); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, atAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, hashOfSecret, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, lastTransactionSignature, lockTimeA); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -73,14 +121,16 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) .bind("trade_state", tradeBotData.getState().value) .bind("at_address", tradeBotData.getAtAddress()) - .bind("locktime_a", tradeBotData.getLockTimeA()) .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) + .bind("trade_native_address", tradeBotData.getTradeNativeAddress()) .bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret()) .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) - .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()); + .bind("xprv58", tradeBotData.getXprv58()) + .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) + .bind("locktime_a", tradeBotData.getLockTimeA()); try { saveHelper.execute(this.repository); @@ -89,4 +139,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { } } -} + @Override + public int delete(byte[] tradePrivateKey) throws DataException { + try { + return this.repository.delete("TradeBotStates", "trade_private_key = ?", tradePrivateKey); + } catch (SQLException e) { + throw new DataException("Unable to delete trade-bot states from repository", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index df08efcb..3ea10454 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -623,9 +623,9 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "at_address QortalAddress, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " - + "secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, " + + "trade_native_address QortalAddress NOT NULL, secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, " + "trade_foreign_public_key VARBINARY(33) NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " - + "bitcoin_amount BIGINT NOT NULL, last_transaction_signature Signature, locktime_a BIGINT, " + + "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, " + "PRIMARY KEY (trade_private_key))"); break; diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index b9f7869a..1b6123a7 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -62,4 +62,15 @@ public class BtcTests extends Common { assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); } + @Test + public void testBuildSpend() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + btc.buildSpend(xprv58, recipient, amount); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java index 3a958c79..992af2ee 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -12,8 +12,8 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.Test; import org.qortal.crosschain.ElectrumX; +import org.qortal.crosschain.ElectrumX.UnspentOutput; import org.qortal.utils.BitTwiddling; -import org.qortal.utils.Pair; import com.google.common.hash.HashCode; @@ -100,13 +100,13 @@ public class ElectrumXTests { Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List> unspentOutputs = electrumX.getUnspentOutputs(script); + List unspentOutputs = electrumX.getUnspentOutputs(script); assertNotNull(unspentOutputs); assertFalse(unspentOutputs.isEmpty()); - for (Pair unspentOutput : unspentOutputs) - System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.getA()).toString(), unspentOutput.getB())); + for (UnspentOutput unspentOutput : unspentOutputs) + System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.hash), unspentOutput.index)); } @Test