From 514689d2f4bf26174947adff3724235adf6a6c3b Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 18 Sep 2020 17:07:49 +0100 Subject: [PATCH] WIP: refactoring to support multiple foreign blockchains API support for Litecoin wallet balance and sending LTC. TradeBotCreateRequest rejigged to use blockchain-agnostic field names, e.g. bitcoinAmount now foreignAmount, and added foreignBlockchain field. The massive API CrossChainResource class has been split into: CrossChainAtResource: for building TRADE/REDEEM/CANCEL messages (OFFER missing?) CrossChainBitcoinResource: for Bitcoin wallet balance/spend CrossChainLitecoinResource: ditto for Litecoin CrossChainHtlcResource: for Bitcoiny-HTLC actions like: deriving P2SH address checking HTLC status eventually: building refund/redeem transactions CrossChainResource: for creating/cancelling/listing trade offers. CrossChainTradeBotResource: for creating/cancelling trade-bot entries, including responding to trade offers. --- Other general trading changes: TradeBot states are now specific to each individual trade-bot, e.g. BitcoinACCTv1TradeBot or LitecoinACCTv1TradeBot, etc. TradeBot states now a combination of int & String, instead of enums due to above. Extra columns added to DB TradeBotStates to store blockchain, which ACCT in use, etc. --- UNTESTED at this point! --- src/main/java/org/qortal/api/ApiError.java | 10 +- .../{ => crosschain}/BitcoinSendRequest.java | 10 +- .../model/crosschain/LitecoinSendRequest.java | 29 + .../TradeBotCreateRequest.java | 22 +- .../TradeBotRespondRequest.java | 10 +- .../api/resource/CrossChainAtResource.java | 329 ++++++ .../resource/CrossChainBitcoinResource.java | 125 +++ .../api/resource/CrossChainHtlcResource.java | 176 ++++ .../resource/CrossChainLitecoinResource.java | 125 +++ .../api/resource/CrossChainResource.java | 958 +----------------- .../resource/CrossChainTradeBotResource.java | 273 +++++ .../api/websocket/TradeBotWebSocket.java | 10 +- .../org/qortal/controller/Controller.java | 1 + .../controller/tradebot/AcctTradeBot.java | 25 + .../BitcoinACCTv1TradeBot.java} | 641 ++++++------ .../qortal/controller/tradebot/TradeBot.java | 295 ++++++ src/main/java/org/qortal/crosschain/ACCT.java | 16 + .../org/qortal/crosschain/BitcoinACCTv1.java | 26 +- .../java/org/qortal/crosschain/Bitcoiny.java | 43 +- .../qortal/crosschain/ForeignBlockchain.java | 9 + .../org/qortal/crosschain/LitecoinACCTv1.java | 26 +- .../crosschain/SupportedBlockchain.java | 80 ++ .../qortal/data/crosschain/TradeBotData.java | 83 +- .../hsqldb/HSQLDBCrossChainRepository.java | 122 +-- .../hsqldb/HSQLDBDatabaseUpdates.java | 33 + src/main/java/org/qortal/utils/Triple.java | 35 +- .../bitcoinv1/BitcoinACCTv1Tests.java | 22 +- .../litecoinv1/LitecoinACCTv1Tests.java | 20 +- 28 files changed, 2105 insertions(+), 1449 deletions(-) rename src/main/java/org/qortal/api/model/{ => crosschain}/BitcoinSendRequest.java (58%) create mode 100644 src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java rename src/main/java/org/qortal/api/model/{ => crosschain}/TradeBotCreateRequest.java (52%) rename src/main/java/org/qortal/api/model/{ => crosschain}/TradeBotRespondRequest.java (50%) create mode 100644 src/main/java/org/qortal/api/resource/CrossChainAtResource.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java create mode 100644 src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java rename src/main/java/org/qortal/controller/{TradeBot.java => tradebot/BitcoinACCTv1TradeBot.java} (69%) create mode 100644 src/main/java/org/qortal/controller/tradebot/TradeBot.java create mode 100644 src/main/java/org/qortal/crosschain/ACCT.java create mode 100644 src/main/java/org/qortal/crosschain/ForeignBlockchain.java create mode 100644 src/main/java/org/qortal/crosschain/SupportedBlockchain.java diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index 759b8b10..7f9b4cbf 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -15,7 +15,7 @@ public enum ApiError { // COMMON // UNKNOWN(0, 500), JSON(1, 400), - // NO_BALANCE(2, 422), + INSUFFICIENT_BALANCE(2, 422), // NOT_YET_RELEASED(3, 422), UNAUTHORIZED(4, 403), REPOSITORY_ISSUE(5, 500), @@ -126,10 +126,10 @@ public enum ApiError { // Groups GROUP_UNKNOWN(1101, 404), - // Bitcoin - BTC_NETWORK_ISSUE(1201, 500), - BTC_BALANCE_ISSUE(1202, 402), - BTC_TOO_SOON(1203, 408); + // Foreign blockchain + FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500), + FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402), + FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408); private static final Map map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); diff --git a/src/main/java/org/qortal/api/model/BitcoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java similarity index 58% rename from src/main/java/org/qortal/api/model/BitcoinSendRequest.java rename to src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java index f169fe33..86d3d7c8 100644 --- a/src/main/java/org/qortal/api/model/BitcoinSendRequest.java +++ b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java @@ -1,4 +1,4 @@ -package org.qortal.api.model; +package org.qortal.api.model.crosschain; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -9,16 +9,20 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class BitcoinSendRequest { - @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") + @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") public String xprv58; @Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE") public String receivingAddress; - @Schema(description = "Amount of BTC to send") + @Schema(description = "Amount of BTC to send", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long bitcoinAmount; + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 BTC (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + public BitcoinSendRequest() { } diff --git a/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java new file mode 100644 index 00000000..5f215740 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class LitecoinSendRequest { + + @Schema(description = "Litecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Litecoin address ('legacy' P2PKH only)", example = "LiTecoinEaterAddressDontSendhLfzKD") + public String receivingAddress; + + @Schema(description = "Amount of LTC to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long litecoinAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 LTC (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public LitecoinSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java similarity index 52% rename from src/main/java/org/qortal/api/model/TradeBotCreateRequest.java rename to src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java index 622262b0..8185d94e 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java @@ -1,9 +1,11 @@ -package org.qortal.api.model; +package org.qortal.api.model.crosschain; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import org.qortal.crosschain.SupportedBlockchain; + import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) @@ -12,22 +14,30 @@ public class TradeBotCreateRequest { @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB") public byte[] creatorPublicKey; - @Schema(description = "QORT amount paid out on successful trade", example = "80.40200000") + @Schema(description = "QORT amount paid out on successful trade", example = "80.40000000", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long qortAmount; - @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81") + @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "80.50000000", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fundingQortAmount; - @Schema(description = "Bitcoin amount wanted in return", example = "0.00864200") + @Deprecated + @Schema(description = "Bitcoin amount wanted in return. DEPRECATED: use foreignAmount instead", example = "0.00864200", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - public long bitcoinAmount; + public Long bitcoinAmount; + + @Schema(description = "Foreign blockchain. Note: default (BITCOIN) to be removed in the future", example = "Bitcoin", defaultValue = "BITCOIN") + public SupportedBlockchain foreignBlockchain; + + @Schema(description = "Foreign blockchain amount wanted in return", example = "0.00864200", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long foreignAmount; @Schema(description = "Suggested trade timeout (minutes)", example = "10080") public int tradeTimeout; - @Schema(description = "Bitcoin address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") + @Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") public String receivingAddress; public TradeBotCreateRequest() { diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java similarity index 50% rename from src/main/java/org/qortal/api/model/TradeBotRespondRequest.java rename to src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java index 129b6c7e..77275f4c 100644 --- a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java @@ -1,4 +1,4 @@ -package org.qortal.api.model; +package org.qortal.api.model.crosschain; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; @@ -11,9 +11,15 @@ public class TradeBotRespondRequest { @Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") public String atAddress; - @Schema(description = "Bitcoin BIP32 extended private key", example = "xprv___________________________________________________________________________________________________________") + @Deprecated + @Schema(description = "Bitcoin BIP32 extended private key. DEPRECATED: use foreignKey instead", + example = "xprv___________________________________________________________________________________________________________") public String xprv58; + @Schema(description = "Foreign blockchain private key, e.g. BIP32 'm' key for Bitcoin/Litecoin starting with 'xprv'", + example = "xprv___________________________________________________________________________________________________________") + public String foreignKey; + @Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq") public String receivingAddress; diff --git a/src/main/java/org/qortal/api/resource/CrossChainAtResource.java b/src/main/java/org/qortal/api/resource/CrossChainAtResource.java new file mode 100644 index 00000000..a974f046 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainAtResource.java @@ -0,0 +1,329 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.Arrays; +import java.util.Random; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +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.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +@Path("/crosschain/at") +@Tag(name = "Cross-Chain (AT-related)") +public class CrossChainAtResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/trademessage") + @Operation( + summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", + description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainTradeRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { + Security.checkApiCallAllowed(request); + + byte[] tradePublicKey = tradeRequest.tradePublicKey; + + if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Does supplied public key match trade public key? + if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); + if (transactionData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN); + + if (transactionData.getType() != TransactionType.MESSAGE) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + byte[] messageData = messageTransactionData.getData(); + BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData); + if (offerMessageData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + // Good to make MESSAGE + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/redeemmessage") + @Operation( + summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", + description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainSecretRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + + byte[] partnerPublicKey = secretRequest.partnerPublicKey; + + if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.TRADING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + String partnerAddress = Crypto.toAddress(partnerPublicKey); + + // MESSAGE must come from address that AT considers trade partner + if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Good to make MESSAGE + + byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress); + byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/cancelmessage") + @Operation( + summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", + description = "Specify address of cross-chain AT that needs to be cancelled.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" + + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainCancelRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Does supplied public key match AT creator's public key? + if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + // Good to make MESSAGE + + String atCreatorAddress = Crypto.toAddress(creatorPublicKey); + byte[] messageData = BitcoinACCTv1.buildCancelMessage(atCreatorAddress); + + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Must be correct AT - check functionality using code hash + if (!Arrays.equals(atData.getCodeHash(), BitcoinACCTv1.CODE_BYTES_HASH)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + + private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { + long txTimestamp = NTP.getTime(); + + // senderPublicKey could be ephemeral trade public key where there is no corresponding account and hence no reference + String senderAddress = Crypto.toAddress(senderPublicKey); + byte[] lastReference = repository.getAccountRepository().getLastReference(senderAddress); + final boolean requiresPoW = lastReference == null; + + if (requiresPoW) { + Random random = new Random(); + lastReference = new byte[Transformer.SIGNATURE_LENGTH]; + random.nextBytes(lastReference); + } + + int version = 4; + int nonce = 0; + long amount = 0L; + Long assetId = null; // no assetId as amount is zero + Long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + if (requiresPoW) { + messageTransaction.computeNonce(); + } else { + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + } + + ValidationResult result = messageTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + try { + return MessageTransactionTransformer.toBytes(messageTransactionData); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java new file mode 100644 index 00000000..33a38024 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -0,0 +1,125 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.BitcoinSendRequest; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.ForeignBlockchainException; + +@Path("/crosschain/btc") +@Tag(name = "Cross-Chain (Bitcoin)") +public class CrossChainBitcoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns BTC balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private key in base58", + example = "tprv___________________________________________________________________________________________________________" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getBitcoinWalletBalance(String xprv58) { + Security.checkApiCallAllowed(request); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidXprv(xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = bitcoin.getWalletBalance(xprv58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + } + + @POST + @Path("/send") + @Operation( + summary = "Sends BTC from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Bitcoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = BitcoinSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (bitcoinSendRequest.bitcoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (bitcoinSendRequest.feePerByte != null && bitcoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidAddress(bitcoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!bitcoin.isValidXprv(bitcoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = bitcoin.buildSpend(bitcoinSendRequest.xprv58, + bitcoinSendRequest.receivingAddress, + bitcoinSendRequest.bitcoinAmount, + bitcoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + bitcoin.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java new file mode 100644 index 00000000..c6d5c079 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -0,0 +1,176 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.math.BigDecimal; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.TransactionOutput; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.utils.NTP; + +import com.google.common.hash.HashCode; + +@Path("/crosschain/htlc") +@Tag(name = "Cross-Chain (Hash time-locked contracts)") +public class CrossChainHtlcResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") + @Operation( + summary = "Returns HTLC address based on trade info", + description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA}) + public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName, + @PathParam("refundPKH") String refundHex, + @PathParam("locktime") int lockTime, + @PathParam("redeemPKH") String redeemHex, + @PathParam("hashOfSecret") String hashOfSecretHex) { + SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); + if (blockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] refunderPubKeyHash; + byte[] redeemerPubKeyHash; + byte[] hashOfSecret; + + try { + refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); + redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); + + if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } + + try { + hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); + if (hashOfSecret.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); + + Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); + + return bitcoiny.deriveP2shAddress(redeemScript); + } + + @GET + @Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") + @Operation( + summary = "Checks HTLC status", + description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName, + @PathParam("refundPKH") String refundHex, + @PathParam("locktime") int lockTime, + @PathParam("redeemPKH") String redeemHex, + @PathParam("hashOfSecret") String hashOfSecretHex) { + Security.checkApiCallAllowed(request); + + SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); + if (blockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] refunderPubKeyHash; + byte[] redeemerPubKeyHash; + byte[] hashOfSecret; + + try { + refunderPubKeyHash = HashCode.fromString(refundHex).asBytes(); + redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes(); + + if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } + + try { + hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes(); + if (hashOfSecret.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret); + + Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); + + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScript); + + long now = NTP.getTime(); + + try { + int medianBlockTime = bitcoiny.getMedianBlockTime(); + + // Check P2SH is funded + long p2shBalance = bitcoiny.getConfirmedBalance(p2shAddress.toString()); + + CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus(); + htlcStatus.bitcoinP2shAddress = p2shAddress; + htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); + + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString()); + + if (p2shBalance > 0L && !fundingOutputs.isEmpty()) { + htlcStatus.canRedeem = now >= medianBlockTime * 1000L; + htlcStatus.canRefund = now >= lockTime * 1000L; + } + + if (now >= medianBlockTime * 1000L) { + // See if we can extract secret + List rawTransactions = bitcoiny.getAddressTransactions(htlcStatus.bitcoinP2shAddress); + htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoiny.getNetworkParameters(), htlcStatus.bitcoinP2shAddress, rawTransactions); + } + + return htlcStatus; + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + // TODO: refund + + // TODO: redeem + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java new file mode 100644 index 00000000..9ff26c06 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -0,0 +1,125 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.LitecoinSendRequest; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; + +@Path("/crosschain/ltc") +@Tag(name = "Cross-Chain (Bitcoin)") +public class CrossChainLitecoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns LTC balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private key in base58", + example = "tprv___________________________________________________________________________________________________________" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getLitecoinWalletBalance(String xprv58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidXprv(xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = litecoin.getWalletBalance(xprv58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + } + + @POST + @Path("/send") + @Operation( + summary = "Sends LTC from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Litecoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = LitecoinSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String sendBitcoin(LitecoinSendRequest litecoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (litecoinSendRequest.litecoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (litecoinSendRequest.feePerByte != null && litecoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidAddress(litecoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!litecoin.isValidXprv(litecoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = litecoin.buildSpend(litecoinSendRequest.xprv58, + litecoinSendRequest.receivingAddress, + litecoinSendRequest.litecoinAmount, + litecoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + litecoin.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 050454ae..0ed9d467 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -9,14 +9,11 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Random; -import java.util.function.Function; -import java.util.function.ToIntFunction; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.DELETE; @@ -27,45 +24,22 @@ import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.LegacyAddress; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.core.TransactionOutput; -import org.bitcoinj.script.Script.ScriptType; -import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; 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.CrossChainTradeSummary; -import org.qortal.api.model.TradeBotCreateRequest; -import org.qortal.api.model.TradeBotRespondRequest; -import org.qortal.api.model.BitcoinSendRequest; -import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; -import org.qortal.api.model.CrossChainBitcoinRedeemRequest; -import org.qortal.api.model.CrossChainBitcoinRefundRequest; -import org.qortal.api.model.CrossChainBitcoinTemplateRequest; import org.qortal.api.model.CrossChainBuildRequest; import org.qortal.asset.Asset; -import org.qortal.controller.TradeBot; -import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crosschain.Bitcoiny; -import org.qortal.crosschain.ForeignBlockchainException; import org.qortal.crosschain.AcctMode; -import org.qortal.crosschain.BitcoinyHTLC; 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.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; @@ -77,7 +51,6 @@ import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction; -import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.Transformer; @@ -111,6 +84,7 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public List getTradeOffers( + // TODO: we need a param to limit to specific foreign blockchain(s) @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { @@ -118,15 +92,19 @@ public class CrossChainResource { if (limit != null && limit > 100) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // TODO: we need to turn this into a List byte[] codeHash = BitcoinACCTv1.CODE_BYTES_HASH; boolean isExecutable = true; try (final Repository repository = RepositoryManager.getRepository()) { + // TODO: we need a list form of getATsByFunctionality List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); List crossChainTradesData = new ArrayList<>(); for (ATData atData : atsData) { - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); + // TODO: we need to map codeHash to ACCT classes and then call .populateTradeData on them + // or make each ACCT extend/implement a superclass/interface? + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); crossChainTradesData.add(crossChainTradeData); } @@ -136,10 +114,11 @@ public class CrossChainResource { } } + @Deprecated @POST @Path("/build") @Operation( - summary = "Build cross-chain trading AT", + summary = "Build Bitcoin cross-chain trading AT", description = "Returns raw, unsigned DEPLOY_AT transaction", requestBody = @RequestBody( required = true, @@ -225,160 +204,6 @@ public class CrossChainResource { } } - @POST - @Path("/tradeoffer/trademessage") - @Operation( - summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", - description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.
" - + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" - + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainTradeRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content( - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { - Security.checkApiCallAllowed(request); - - byte[] tradePublicKey = tradeRequest.tradePublicKey; - - if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != AcctMode.OFFERING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - // Does supplied public key match trade public key? - if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); - if (transactionData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN); - - if (transactionData.getType() != TransactionType.MESSAGE) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); - - MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; - byte[] messageData = messageTransactionData.getData(); - BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData); - if (offerMessageData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); - - // Good to make MESSAGE - - byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; - byte[] hashOfSecretA = offerMessageData.hashOfSecretA; - int lockTimeA = (int) offerMessageData.lockTimeA; - - String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); - int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); - - byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); - - return Base58.encode(messageTransactionBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/tradeoffer/redeemmessage") - @Operation( - summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", - description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.
" - + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" - + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainSecretRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content( - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { - Security.checkApiCallAllowed(request); - - byte[] partnerPublicKey = secretRequest.partnerPublicKey; - - if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != AcctMode.TRADING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - String partnerAddress = Crypto.toAddress(partnerPublicKey); - - // MESSAGE must come from address that AT considers trade partner - if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Good to make MESSAGE - - byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress); - byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData); - - return Base58.encode(messageTransactionBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - @DELETE @Path("/tradeoffer") @Operation( @@ -406,7 +231,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { + public String cancelTrade(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); byte[] creatorPublicKey = cancelRequest.creatorPublicKey; @@ -419,7 +244,7 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); if (crossChainTradeData.mode != AcctMode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -441,769 +266,6 @@ public class CrossChainResource { } } - @POST - @Path("/p2sh/a") - @Operation( - summary = "Returns Bitcoin P2SH-A address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - @POST - @Path("/p2sh/b") - @Operation( - summary = "Returns Bitcoin P2SH-B address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - private String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - Bitcoin bitcoin = Bitcoin.getInstance(); - NetworkParameters params = bitcoin.getNetworkParameters(); - - if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == AcctMode.OFFERING || crossChainTradeData.mode == AcctMode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - return p2shAddress.toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/p2sh/a/check") - @Operation( - summary = "Checks Bitcoin P2SH-A address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - public CrossChainBitcoinyHTLCStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); - } - - @POST - @Path("/p2sh/b/check") - @Operation( - summary = "Checks Bitcoin P2SH-B address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - public CrossChainBitcoinyHTLCStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); - } - - private CrossChainBitcoinyHTLCStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - Bitcoin bitcoin = Bitcoin.getInstance(); - NetworkParameters params = bitcoin.getNetworkParameters(); - - if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == AcctMode.OFFERING || crossChainTradeData.mode == AcctMode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - - int medianBlockTime = bitcoin.getMedianBlockTime(); - - long now = NTP.getTime(); - - // Check P2SH is funded - long p2shBalance = bitcoin.getConfirmedBalance(p2shAddress.toString()); - - CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus(); - htlcStatus.bitcoinP2shAddress = p2shAddress.toString(); - htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); - - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddress.toString()); - - if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { - htlcStatus.canRedeem = now >= medianBlockTime * 1000L; - htlcStatus.canRefund = now >= lockTime * 1000L; - } - - if (now >= medianBlockTime * 1000L) { - // See if we can extract secret - List rawTransactions = bitcoin.getAddressTransactions(htlcStatus.bitcoinP2shAddress); - htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoin.getNetworkParameters(), htlcStatus.bitcoinP2shAddress, rawTransactions); - } - - return htlcStatus; - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (ForeignBlockchainException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @Path("/p2sh/a/refund") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRefundRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - @POST - @Path("/p2sh/b/refund") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-B address", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRefundRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - private String refundP2sh(CrossChainBitcoinRefundRequest refundRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - Bitcoin bitcoin = Bitcoin.getInstance(); - - byte[] refundPrivateKey = refundRequest.refundPrivateKey; - if (refundPrivateKey == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - ECKey refundKey = null; - - try { - // Auto-trim - if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38) - refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33); - if (refundPrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - refundKey = ECKey.fromPrivate(refundPrivateKey); - } catch (IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } - - if (refundRequest.redeemPublicKeyHash == null || refundRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - 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); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == AcctMode.OFFERING || crossChainTradeData.mode == AcctMode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret); - String p2shAddress = bitcoin.deriveP2shAddress(redeemScriptBytes); - - long now = NTP.getTime(); - - // Check P2SH is funded - - long p2shBalance = bitcoin.getConfirmedBalance(p2shAddress); - - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddress); - if (fundingOutputs.isEmpty()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - boolean canRefund = now >= lockTime * 1000L; - if (!canRefund) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - - if (p2shBalance < crossChainTradeData.expectedBitcoin) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - - org.bitcoinj.core.Transaction refundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptBytes, lockTime, refundRequest.receivingAccountInfo); - bitcoin.broadcastTransaction(refundTransaction); - - return refundTransaction.getTxId().toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (ForeignBlockchainException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @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( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRedeemRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - @POST - @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( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRedeemRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @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); - } - - private String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - Bitcoin bitcoin = Bitcoin.getInstance(); - - byte[] redeemPrivateKey = redeemRequest.redeemPrivateKey; - if (redeemPrivateKey == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - ECKey redeemKey = null; - - try { - // Auto-trim - if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38) - redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33); - if (redeemPrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - redeemKey = ECKey.fromPrivate(redeemPrivateKey); - } catch (IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } - - if (redeemRequest.refundPublicKeyHash == null || redeemRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (redeemRequest.atAddress == null || !Crypto.isValidAtAddress(redeemRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (redeemRequest.secret == null || redeemRequest.secret.length != BitcoinACCTv1.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (redeemRequest.receivingAccountInfo == null) - redeemRequest.receivingAccountInfo = redeemKey.getPubKeyHash(); - - if (redeemRequest.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, redeemRequest.atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == AcctMode.OFFERING || crossChainTradeData.mode == AcctMode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret); - String p2shAddress = bitcoin.deriveP2shAddress(redeemScriptBytes); - - int medianBlockTime = bitcoin.getMedianBlockTime(); - - long now = NTP.getTime(); - - // Check P2SH is funded - long p2shBalance = bitcoin.getConfirmedBalance(p2shAddress); - - if (p2shBalance < crossChainTradeData.expectedBitcoin) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddress); - if (fundingOutputs.isEmpty()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - boolean canRedeem = now >= medianBlockTime * 1000L; - if (!canRedeem) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - - Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - - org.bitcoinj.core.Transaction redeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, - fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo); - - bitcoin.broadcastTransaction(redeemTransaction); - - return redeemTransaction.getTxId().toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (ForeignBlockchainException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @Path("/btc/walletbalance") - @Operation( - summary = "Returns BTC balance for BIP32 wallet", - description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string", - description = "BIP32 'm' private key in base58", - example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ" - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) - public String getBitcoinWalletBalance(String xprv58) { - Security.checkApiCallAllowed(request); - - if (!Bitcoin.getInstance().isValidXprv(xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - Long balance = Bitcoin.getInstance().getWalletBalance(xprv58); - if (balance == null) - return "null"; - - return balance.toString(); - } - - @POST - @Path("/btc/send") - @Operation( - summary = "Sends BTC from BIP32 wallet to specific address", - description = "Currently only supports 'legacy' P2PKH Bitcoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = BitcoinSendRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE}) - public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { - Security.checkApiCallAllowed(request); - - if (bitcoinSendRequest.bitcoinAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - Address receivingAddress; - try { - receivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), bitcoinSendRequest.receivingAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - } - - // We only support P2PKH addresses at this time - if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (!Bitcoin.getInstance().isValidXprv(bitcoinSendRequest.xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - org.bitcoinj.core.Transaction spendTransaction = Bitcoin.getInstance().buildSpend(bitcoinSendRequest.xprv58, bitcoinSendRequest.receivingAddress, bitcoinSendRequest.bitcoinAmount); - if (spendTransaction == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - try { - Bitcoin.getInstance().broadcastTransaction(spendTransaction); - } catch (ForeignBlockchainException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - - return "true"; - } - - @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( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = TradeBotCreateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { - Security.checkApiCallAllowed(request); - - Address receivingAddress; - try { - receivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - } - - // We only support P2PKH addresses at this time - if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (tradeBotCreateRequest.tradeTimeout < 60) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - if (tradeBotCreateRequest.bitcoinAmount <= 0 || tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - try (final Repository repository = RepositoryManager.getRepository()) { - // Do some simple checking first - Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); - if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount) - throw TransactionsResource.createTransactionInvalidException(request, ValidationResult.NO_BALANCE); - - byte[] unsignedBytes = TradeBot.createTrade(repository, tradeBotCreateRequest); - - return Base58.encode(unsignedBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/tradebot/respond") - @Operation( - 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")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - 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); - - if (!Bitcoin.getInstance().isValidXprv(tradeBotRespondRequest.xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, atAddress); - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != AcctMode.OFFERING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - TradeBot.ResponseResult result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress); - - switch (result) { - case OK: - return "true"; - - case INSUFFICIENT_FUNDS: - case BTC_BALANCE_ISSUE: - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - case BTC_NETWORK_ISSUE: - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - - default: - return "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 == null) - return "false"; - - switch (tradeBotData.getState()) { - case BOB_WAITING_FOR_AT_CONFIRM: - case ALICE_DONE: - case BOB_DONE: - case ALICE_REFUNDED: - case BOB_REFUNDED: - break; - - default: - return "false"; - } - - repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); - repository.saveChanges(); - - return "true"; - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - @GET @Path("/trades") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java new file mode 100644 index 00000000..225ccc63 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -0,0 +1,273 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.model.crosschain.TradeBotRespondRequest; +import org.qortal.asset.Asset; +import org.qortal.controller.tradebot.AcctTradeBot; +import org.qortal.controller.tradebot.TradeBot; +import org.qortal.crosschain.ForeignBlockchain; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +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.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@Path("/crosschain/tradebot") +@Tag(name = "Cross-Chain (Trade-Bot)") +public class CrossChainTradeBotResource { + + @Context + HttpServletRequest request; + + @GET + @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( + // TODO: optional filter for foreign blockchain(s)? + ) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + // TODO: maybe sub-class returned trade-bot data according to which trade-bot/ACCT applies? + return repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/create") + @Operation( + summary = "Create a trade offer (trade-bot entry)", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotCreateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + Security.checkApiCallAllowed(request); + + ForeignBlockchain foreignBlockchain = tradeBotCreateRequest.foreignBlockchain.getInstance(); + + // We prefer foreignAmount to deprecated bitcoinAmount + if (tradeBotCreateRequest.foreignAmount == null) + tradeBotCreateRequest.foreignAmount = tradeBotCreateRequest.bitcoinAmount; + + if (!foreignBlockchain.isValidAddress(tradeBotCreateRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeBotCreateRequest.tradeTimeout < 60) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + // Do some simple checking first + Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INSUFFICIENT_BALANCE); + + byte[] unsignedBytes = TradeBot.getInstance().createTrade(repository, tradeBotCreateRequest); + if (unsignedBytes == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return Base58.encode(unsignedBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/respond") + @Operation( + summary = "Respond to a trade offer. NOTE: WILL SPEND FUNDS!)", + description = "Start a new trade-bot entry to respond to chosen trade offer.", + 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")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + final String atAddress = tradeBotRespondRequest.atAddress; + + // We prefer foreignKey to deprecated xprv58 + if (tradeBotRespondRequest.foreignKey == null) + tradeBotRespondRequest.foreignKey = tradeBotRespondRequest.xprv58; + + if (tradeBotRespondRequest.foreignKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, atAddress); + + // TradeBot uses AT's code hash to map to ACCT + ACCT acct = TradeBot.getInstance().getAcctUsingAtData(atData); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!acct.getBlockchain().isValidWalletKey(tradeBotRespondRequest.foreignKey)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData, + tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress); + + switch (result) { + case OK: + return "true"; + + case BALANCE_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + case NETWORK_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + default: + return "false"; + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Operation( + summary = "Delete completed trade", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "93MB2qRDNVLxbmmPuYpLdAqn3u2x9ZhaVZK5wELHueP8" + ) + ) + ), + 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()) { + // Handed off to TradeBot + return TradeBot.getInstance().deleteEntry(repository, tradePrivateKey) ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java index a52b7d8b..7ec852f9 100644 --- a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -15,7 +15,7 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; import org.eclipse.jetty.websocket.api.annotations.WebSocket; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; -import org.qortal.controller.TradeBot; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.data.crosschain.TradeBotData; import org.qortal.event.Event; import org.qortal.event.EventBus; @@ -30,7 +30,7 @@ import org.qortal.utils.Base58; public class TradeBotWebSocket extends ApiWebSocket implements Listener { /** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */ - private static final Map PREVIOUS_STATES = new HashMap<>(); + private static final Map PREVIOUS_STATES = new HashMap<>(); @Override public void configure(WebSocketServletFactory factory) { @@ -42,7 +42,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { // How do we properly fail here? return; - PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState))); + PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue))); } catch (DataException e) { // No output this time } @@ -59,11 +59,11 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey()); synchronized (PREVIOUS_STATES) { - if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState()) + if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getStateValue()) // Not changed return; - PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState()); + PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue()); } List tradeBotEntries = Collections.singletonList(tradeBotData); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 4e9c6e76..c9510742 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -38,6 +38,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; diff --git a/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java new file mode 100644 index 00000000..06f03fb5 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java @@ -0,0 +1,25 @@ +package org.qortal.controller.tradebot; + +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +public interface AcctTradeBot { + + public enum ResponseResult { OK, BALANCE_ISSUE, NETWORK_ISSUE } + + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException; + + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, + CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException; + + public boolean canDelete(Repository repository, TradeBotData tradeBotData); + + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException; + +} diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java similarity index 69% rename from src/main/java/org/qortal/controller/TradeBot.java rename to src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index ca44d08d..16661365 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -1,14 +1,14 @@ -package org.qortal.controller; +package org.qortal.controller.tradebot; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; -import java.awt.TrayIcon.MessageType; -import java.security.SecureRandom; import java.util.Arrays; import java.util.List; -import java.util.Random; +import java.util.Map; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.util.Supplier; import org.bitcoinj.core.Address; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; @@ -18,36 +18,30 @@ import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; -import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; import org.qortal.asset.Asset; +import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crosschain.BitcoinyHTLC; 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; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.event.Event; -import org.qortal.event.EventBus; -import org.qortal.event.Listener; import org.qortal.group.Group; -import org.qortal.gui.SysTray; import org.qortal.repository.DataException; import org.qortal.repository.Repository; -import org.qortal.repository.RepositoryManager; -import org.qortal.settings.Settings; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; -import org.qortal.utils.Amounts; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -57,43 +51,71 @@ import org.qortal.utils.NTP; * We deal with three different independent state-spaces here: *
    *
  • Qortal blockchain
  • - *
  • Bitcoin blockchain
  • + *
  • Foreign blockchain
  • *
  • Trade-bot entries
  • *
*/ -public class TradeBot implements Listener { +public class BitcoinACCTv1TradeBot implements AcctTradeBot { - public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } + private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv1TradeBot.class); - public static class StateChangeEvent implements Event { - private final TradeBotData tradeBotData; + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, false), + BOB_WAITING_FOR_P2SH_B(20, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), - public StateChangeEvent(TradeBotData tradeBotData) { - this.tradeBotData = tradeBotData; + ALICE_WAITING_FOR_P2SH_A(80, true, true), + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_WATCH_P2SH_B(90, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_B(100, true, true), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); + + public final int value; + public final boolean requiresAtData; + public final boolean requiresTradeData; + + State(int value, boolean requiresAtData, boolean requiresTradeData) { + this.value = value; + this.requiresAtData = requiresAtData; + this.requiresTradeData = requiresTradeData; } - public TradeBotData getTradeBotData() { - return this.tradeBotData; + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; } } - private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); - private static final Random RANDOM = new SecureRandom(); - - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. */ + /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms - private static final long P2SH_B_OUTPUT_AMOUNT = 1000L; // P2SH-B output amount needs to be higher than the dust threshold (3000 sats/kB). + /** P2SH-B output amount to avoid dust threshold (3000 sats/kB). */ + private static final long P2SH_B_OUTPUT_AMOUNT = 1000L; - private static TradeBot instance; + private static BitcoinACCTv1TradeBot instance; - private TradeBot() { - EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); + private BitcoinACCTv1TradeBot() { } - public static synchronized TradeBot getInstance() { + public static synchronized BitcoinACCTv1TradeBot getInstance() { if (instance == null) - instance = new TradeBot(); + instance = new BitcoinACCTv1TradeBot(); return instance; } @@ -130,16 +152,16 @@ public class TradeBot implements Listener { * @return raw, unsigned DEPLOY_AT transaction * @throws DataException */ - public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { - byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secretB = generateSecret(); + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretB = TradeBot.generateSecret(); byte[] hashOfSecretB = Crypto.hash160(secretB); - byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) @@ -168,7 +190,7 @@ public class TradeBot implements Listener { String aTType = "ACCT"; String tags = "ACCT QORT BTC"; byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, - tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -180,15 +202,16 @@ public class TradeBot implements Listener { DeployAtTransaction.ensureATAddress(deployAtTransactionData); String atAddress = deployAtTransactionData.getAtAddress(); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretB, hashOfSecretB, + SupportedBlockchain.BITCOIN.name(), tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); + tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo); - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), - () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + TradeBot.updateTradeBotState(repository, tradeBotData, () -> 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 { @@ -236,26 +259,28 @@ public class TradeBot implements Listener { * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise * @throws DataException */ - public static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { - byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secretA = generateSecret(); + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); byte[] hashOfSecretA = Crypto.hash160(secretA); - byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH // We need to generate lockTime-A: add tradeTimeout to now int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.ALICE_WAITING_FOR_P2SH_A.name(), State.ALICE_WAITING_FOR_P2SH_A.value, receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretA, hashOfSecretA, + SupportedBlockchain.BITCOIN.name(), tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash); @@ -267,7 +292,7 @@ public class TradeBot implements Listener { p2shFee = Bitcoin.getInstance().getP2shFee(lockTimeA * 1000L); } catch (ForeignBlockchainException e) { LOGGER.debug("Couldn't estimate Bitcoin fees?"); - return ResponseResult.BTC_NETWORK_ISSUE; + return ResponseResult.NETWORK_ISSUE; } // Fee for redeem/refund is subtracted from P2SH-A balance. @@ -278,7 +303,7 @@ public class TradeBot implements Listener { // As buildSpend also adds a fee, this is more pessimistic than required Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) - return ResponseResult.INSUFFICIENT_FUNDS; + return ResponseResult.BALANCE_ISSUE; // P2SH-A to be funded byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); @@ -289,10 +314,10 @@ public class TradeBot implements Listener { // Do not include fee for funding transaction as this is covered by buildSpend() long amountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; - Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, amountA); + Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); if (p2shFundingTransaction == null) { LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); - return ResponseResult.BTC_BALANCE_ISSUE; + return ResponseResult.BALANCE_ISSUE; } try { @@ -300,104 +325,105 @@ public class TradeBot implements Listener { } catch (ForeignBlockchainException e) { // We couldn't fund P2SH-A at this time LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); - return ResponseResult.BTC_NETWORK_ISSUE; + return ResponseResult.NETWORK_ISSUE; } - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), - () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); return ResponseResult.OK; } - private static byte[] generateTradePrivateKey() { - // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. - // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. - return new ECKey().getPrivKeyBytes(); - } + @Override + public boolean canDelete(Repository repository, TradeBotData tradeBotData) { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) + return true; - private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { - return PrivateKeyAccount.toPublicKey(privateKey); - } + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + return true; - private static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { - return ECKey.fromPrivate(privateKey).getPubKey(); - } - - private static byte[] generateSecret() { - byte[] secret = new byte[32]; - RANDOM.nextBytes(secret); - return secret; + default: + return false; + } } @Override - public void listen(Event event) { - if (!(event instanceof Controller.NewBlockEvent)) + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) { + LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress())); return; + } - synchronized (this) { - // Get repo for trade situations - try (final Repository repository = RepositoryManager.getRepository()) { - List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + ATData atData = null; + CrossChainTradeData tradeData = null; - for (TradeBotData tradeBotData : allTradeBotData) { - repository.discardChanges(); - - try { - switch (tradeBotData.getState()) { - case BOB_WAITING_FOR_AT_CONFIRM: - 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_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; - - 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())); - } - } catch (ForeignBlockchainException e) { - LOGGER.warn(() -> String.format("Bitcoin issue processing %s: %s", tradeBotData.getAtAddress(), e.getMessage())); - } - } - } catch (DataException e) { - LOGGER.error("Couldn't run trade bot due to repository issue", e); + if (tradeBotState.requiresAtData) { + // Attempt to fetch AT data + atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; } + + if (tradeBotState.requiresTradeData) { + tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + if (tradeData == null) { + LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress())); + return; + } + } + } + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; + + case ALICE_WAITING_FOR_P2SH_A: + handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_MESSAGE: + handleBobWaitingForMessage(repository, tradeBotData, atData); + break; + + case ALICE_WAITING_FOR_AT_LOCK: + handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_P2SH_B: + handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WATCH_P2SH_B: + handleAliceWatchingP2shB(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_DONE: + case BOB_DONE: + break; + + case ALICE_REFUNDING_B: + handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDING_A: + handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; } } @@ -413,18 +439,19 @@ public class TradeBot implements Listener { // We've waited ages for AT to be confirmed into a block but something has gone awry. // After this long we assume transaction loss so give up with trade-bot entry too. - tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setState(State.BOB_REFUNDED.name()); + tradeBotData.setStateValue(State.BOB_REFUNDED.value); tradeBotData.setTimestamp(NTP.getTime()); // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); repository.saveChanges(); LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + TradeBot.notifyStateChange(tradeBotData); return; } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } @@ -445,27 +472,16 @@ public class TradeBot implements Listener { * If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. * @throws ForeignBlockchainException */ - private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); Bitcoin bitcoin = Bitcoin.getInstance(); byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); - // If AT has finished then maybe Bob cancelled his trade offer - if (atData.getIsFinished()) { - // No point sending MESSAGE - might as well wait for refund - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, - () -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)); - return; - } - // Fee for redeem/refund is subtracted from P2SH-A balance. long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); @@ -478,13 +494,13 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); return; @@ -517,7 +533,7 @@ public class TradeBot implements Listener { } } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_AT_LOCK, () -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", p2shAddressA, messageRecipient, tradeBotData.getAtAddress())); } @@ -541,17 +557,10 @@ public class TradeBot implements Listener { * needed by Alice to progress her side of the trade. * @throws ForeignBlockchainException */ - private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - // 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())); - return; - } - + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, ATData atData) throws DataException, ForeignBlockchainException { // If AT has finished then Bob likely cancelled his trade offer if (atData.getIsFinished()) { - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); return; } @@ -592,7 +601,7 @@ public class TradeBot implements Listener { byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); - final long minimumAmountA = tradeBotData.getBitcoinAmount() - P2SH_B_OUTPUT_AMOUNT; + final long minimumAmountA = tradeBotData.getForeignAmount() - P2SH_B_OUTPUT_AMOUNT; BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); @@ -605,7 +614,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; @@ -649,7 +658,7 @@ public class TradeBot implements Listener { byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, () -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB)); return; @@ -657,7 +666,7 @@ public class TradeBot implements Listener { // Don't resave/notify if we don't need to if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), null); + TradeBot.updateTradeBotState(repository, tradeBotData, null); } /** @@ -675,18 +684,15 @@ public class TradeBot implements Listener { * step is to watch for Bob revealing secret-B by redeeming P2SH-B. * @throws ForeignBlockchainException */ - private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); Bitcoin bitcoin = Bitcoin.getInstance(); - // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A - if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { + // Refund P2SH-A if we've passed lockTime-A + if (NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); @@ -697,20 +703,20 @@ public class TradeBot implements Listener { case UNFUNDED: case FUNDING_IN_PROGRESS: // This shouldn't occur, but defensively revert back to waiting for P2SH-A - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_P2SH_A, () -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA)); return; case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); return; @@ -719,7 +725,7 @@ public class TradeBot implements Listener { break; } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> atData.getIsFinished() ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA)); @@ -731,22 +737,7 @@ public class TradeBot implements Listener { if (crossChainTradeData.mode != AcctMode.TRADING) return; - // We're expecting AT to be locked to our native trade address - if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) { - // AT locked to different address! We shouldn't continue but wait and refund. - - byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddress = bitcoin.deriveP2shAddress(redeemScriptBytes); - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, - () -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", - tradeBotData.getAtAddress(), - crossChainTradeData.qortalPartnerAddress, - tradeBotData.getTradeNativeAddress(), - p2shAddress)); - - return; - } + // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above // Alice needs to fund P2SH-B here @@ -780,7 +771,20 @@ public class TradeBot implements Listener { BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); switch (htlcStatusB) { - case UNFUNDED: + case UNFUNDED: { + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/; + + Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getForeignKey(), p2shAddressB, amountB); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?"); + return; + } + + bitcoin.broadcastTransaction(p2shFundingTransaction); + break; + } + case FUNDING_IN_PROGRESS: case FUNDED: break; @@ -788,32 +792,19 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-B %s already spent? Defensively checking P2SH-B next", p2shAddressB)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); return; } - if (htlcStatusB == BitcoinyHTLC.Status.UNFUNDED) { - // Do not include fee for funding transaction as this is covered by buildSpend() - long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/; - - Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getXprv58(), p2shAddressB, amountB); - if (p2shFundingTransaction == null) { - LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?"); - return; - } - - bitcoin.broadcastTransaction(p2shFundingTransaction); - } - // P2SH-B funded, now we wait for Bob to redeem it - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB)); } @@ -829,17 +820,11 @@ public class TradeBot implements Listener { * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. * @throws ForeignBlockchainException */ - private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - 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 = BitcoinACCTv1.populateTradeData(repository, atData); - + private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { // If we've passed AT refund timestamp then AT will have finished after auto-refunding if (atData.getIsFinished()) { - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); return; @@ -871,7 +856,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, () -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); return; @@ -896,7 +881,7 @@ public class TradeBot implements Listener { bitcoin.broadcastTransaction(p2shRedeemTransaction); // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, () -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); } @@ -917,22 +902,10 @@ public class TradeBot implements Listener { * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. * @throws ForeignBlockchainException */ - private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData); - - // We check variable in AT that is set when Bob is refunded - if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REFUNDED) { - // Bob bailed out of trade so we must start refunding too - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_B, - () -> String.format("AT %s has auto-refunded, Attempting refund also", tradeBotData.getAtAddress())); - - return; - } Bitcoin bitcoin = Bitcoin.getInstance(); @@ -960,7 +933,7 @@ public class TradeBot implements Listener { case REFUND_IN_PROGRESS: case REFUNDED: // We've refunded P2SH-B? Bump to refunding P2SH-A then - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); return; } @@ -996,7 +969,7 @@ public class TradeBot implements Listener { } } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, () -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress)); } @@ -1015,30 +988,17 @@ public class TradeBot implements Listener { * If trade-bot successfully broadcasts the transaction, then this specific trade is done. * @throws ForeignBlockchainException */ - private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - 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 = BitcoinACCTv1.populateTradeData(repository, atData); - + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { // AT should be 'finished' once Alice has redeemed QORT funds if (!atData.getIsFinished()) // Not finished yet return; - // If AT's balance should be zero - AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT); - if (atBalanceData != null && atBalanceData.getBalance() > 0L) { - LOGGER.debug(() -> String.format("AT %s should have zero balance, not %s", tradeBotData.getAtAddress(), Amounts.prettyAmount(atBalanceData.getBalance()))); - return; - } - - // We check variable in AT that is set when trade successfully completes + // If AT is not REDEEMED then something has gone wrong if (crossChainTradeData.mode != AcctMode.REDEEMED) { - // Not redeemed so must be refunded - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + // Not redeemed so must be refunded/cancelled + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); return; @@ -1078,25 +1038,22 @@ public class TradeBot implements Listener { // Wait for AT to auto-refund return; - case FUNDED: - // Fall-through out of switch... + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + bitcoin.broadcastTransaction(p2shRedeemTransaction); break; - } - - if (htlcStatusA == BitcoinyHTLC.Status.FUNDED) { - Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); - ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); - - Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, - fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); - - bitcoin.broadcastTransaction(p2shRedeemTransaction); + } } String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo); - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); } @@ -1108,14 +1065,8 @@ public class TradeBot implements Listener { * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. * @throws ForeignBlockchainException */ - private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - 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 = BitcoinACCTv1.populateTradeData(repository, atData); - + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { // We can't refund P2SH-B until lockTime-B has passed if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) return; @@ -1138,6 +1089,10 @@ public class TradeBot implements Listener { switch (htlcStatusB) { case UNFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s never funded?. Refunding P2SH-A next", p2shAddressB)); + return; + case FUNDING_IN_PROGRESS: // Still waiting for P2SH-B to be funded... return; @@ -1145,7 +1100,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // We must be very close to trade timeout. Defensively try to refund P2SH-A - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB)); return; @@ -1153,26 +1108,24 @@ public class TradeBot implements Listener { case REFUNDED: break; - case FUNDED: + case FUNDED:{ + Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); break; + } } - if (htlcStatusB == BitcoinyHTLC.Status.FUNDED) { - Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); - - // Determine receive address for refund - String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getXprv58()); - Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); - - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash()); - - bitcoin.broadcastTransaction(p2shRefundTransaction); - } - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB)); } @@ -1180,14 +1133,8 @@ public class TradeBot implements Listener { * Trade-bot is attempting to refund P2SH-A. * @throws ForeignBlockchainException */ - private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { - 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 = BitcoinACCTv1.populateTradeData(repository, atData); - + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { // We can't refund P2SH-A until lockTime-A has passed if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L) return; @@ -1215,7 +1162,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // Too late! - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, () -> String.format("P2SH-A %s already spent!", p2shAddressA)); return; @@ -1223,51 +1170,57 @@ public class TradeBot implements Listener { case REFUNDED: break; - case FUNDED: - // Fall-through out of switch... + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); break; + } } - if (htlcStatusA == BitcoinyHTLC.Status.FUNDED) { - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); - - // Determine receive address for refund - String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getXprv58()); - Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); - - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash()); - - bitcoin.broadcastTransaction(p2shRefundTransaction); - } - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); } - /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ - private static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, TradeBotData.State newState, Supplier logMessageSupplier) throws DataException { - tradeBotData.setState(newState); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + /** + * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. + *

+ * Will automatically update trade-bot state to ALICE_REFUNDING_B or ALICE_DONE as necessary. + * + * @throws DataException + * @throws ForeignBlockchainException + */ + private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // This is OK + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING) + return false; - if (Settings.getInstance().isTradebotSystrayEnabled()) - SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState.name()), MessageType.INFO); + boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); - if (logMessageSupplier != null) - LOGGER.info(logMessageSupplier); + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING && isAtLockedToUs) + return false; - LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState.name())); + if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) { + // We've redeemed already? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress())); + } else { + // Any other state is not good, so start defensive refund + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_B, + () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); + } - notifyStateChange(tradeBotData); - } - - private static void notifyStateChange(TradeBotData tradeBotData) { - StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); - EventBus.INSTANCE.notify(stateChangeEvent); + return true; } } diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java new file mode 100644 index 00000000..0298ccbe --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -0,0 +1,295 @@ +package org.qortal.controller.tradebot; + +import java.awt.TrayIcon.MessageType; +import java.security.SecureRandom; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.util.Supplier; +import org.bitcoinj.core.ECKey; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.controller.Controller; +import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.gui.SysTray; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class TradeBot implements Listener { + + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); + private static final Random RANDOM = new SecureRandom(); + + public interface StateNameAndValueSupplier { + public String getState(); + public int getStateValue(); + } + + public static class StateChangeEvent implements Event { + private final TradeBotData tradeBotData; + + public StateChangeEvent(TradeBotData tradeBotData) { + this.tradeBotData = tradeBotData; + } + + public TradeBotData getTradeBotData() { + return this.tradeBotData; + } + } + + private static final Map, Supplier> acctTradeBotSuppliers = new HashMap<>(); + static { + acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); + // acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); + } + + private static TradeBot instance; + + private TradeBot() { + EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); + } + + public static synchronized TradeBot getInstance() { + if (instance == null) + instance = new TradeBot(); + + return instance; + } + + public ACCT getAcctUsingAtData(ATData atData) { + byte[] codeHash = atData.getCodeHash(); + if (codeHash == null) + return null; + + return SupportedBlockchain.getAcctByCodeHash(codeHash); + } + + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ACCT acct = this.getAcctUsingAtData(atData); + if (acct == null) + return null; + + return acct.populateTradeData(repository, atData); + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, + * i.e. OFFERing QORT in exchange for foreign blockchain currency. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret(s)
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' public key, public key hash
  • + *
  • hash(es) of secret(s)
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native' (Qortal) 'trade' address - used to MESSAGE AT
  • + *
  • 'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain
  • + *
  • hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed
  • + *
  • QORT amount on offer by Bob
  • + *
  • foreign currency amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + // Fetch latest ACCT version for requested foreign blockchain + ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct(); + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) + return null; + + return acctTradeBot.createTrade(repository, tradeBotCreateRequest); + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to an existing QORT offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param foreignKey foreign blockchain wallet key + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, + CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress())); + return ResponseResult.NETWORK_ISSUE; + } + + return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress); + } + + public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException { + TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + if (tradeBotData == null) + // Can't delete what we don't have! + return false; + + boolean canDelete = false; + + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) + // We can't/no longer support this ACCT + canDelete = true; + else { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData); + } + + if (canDelete) + repository.getCrossChainRepository().delete(tradePrivateKey); + + return canDelete; + } + + @Override + public void listen(Event event) { + if (!(event instanceof Controller.NewBlockEvent)) + return; + + synchronized (this) { + List allTradeBotData; + + try (final Repository repository = RepositoryManager.getRepository()) { + allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + return; + } + + for (TradeBotData tradeBotData : allTradeBotData) + try (final Repository repository = RepositoryManager.getRepository()) { + // Find ACCT-specific trade-bot for this entry + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName())); + continue; + } + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName())); + continue; + } + + acctTradeBot.progress(repository, tradeBotData); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + } catch (ForeignBlockchainException e) { + LOGGER.warn(() -> String.format("Bitcoin issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage())); + } + } + } + + /*package*/ static byte[] generateTradePrivateKey() { + // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. + // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. + return new ECKey().getPrivKeyBytes(); + } + + /*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + return PrivateKeyAccount.toPublicKey(privateKey); + } + + /*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { + return ECKey.fromPrivate(privateKey).getPubKey(); + } + + /*package*/ static byte[] generateSecret() { + byte[] secret = new byte[32]; + RANDOM.nextBytes(secret); + return secret; + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, + String newState, int newStateValue, Supplier logMessageSupplier) throws DataException { + tradeBotData.setState(newState); + tradeBotData.setStateValue(newStateValue); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + if (Settings.getInstance().isTradebotSystrayEnabled()) + SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO); + + if (logMessageSupplier != null) + LOGGER.info(logMessageSupplier); + + LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState)); + + notifyStateChange(tradeBotData); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier); + } + + /*package*/ static void notifyStateChange(TradeBotData tradeBotData) { + StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); + EventBus.INSTANCE.notify(stateChangeEvent); + } + + /*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) { + Supplier acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass()); + if (acctTradeBotSupplier == null) + return null; + + return acctTradeBotSupplier.get(); + } + +} diff --git a/src/main/java/org/qortal/crosschain/ACCT.java b/src/main/java/org/qortal/crosschain/ACCT.java new file mode 100644 index 00000000..6a72b1dc --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ACCT.java @@ -0,0 +1,16 @@ +package org.qortal.crosschain; + +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +public interface ACCT { + + public byte[] getCodeBytesHash(); + + public ForeignBlockchain getBlockchain(); + + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException; + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java index 9898d89c..ac043694 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -98,11 +98,12 @@ import com.google.common.primitives.Bytes; * * */ -public class BitcoinACCTv1 { +public class BitcoinACCTv1 implements ACCT { + + public static final String NAME = "BitcoinACCTv1"; + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes public static final int SECRET_LENGTH = 32; - public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ private static final int MODE_VALUE_OFFSET = 68; @@ -123,9 +124,26 @@ public class BitcoinACCTv1 { public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + private static BitcoinACCTv1 instance; + private BitcoinACCTv1() { } + public static synchronized BitcoinACCTv1 getInstance() { + if (instance == null) + instance = new BitcoinACCTv1(); + + return instance; + } + + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + public ForeignBlockchain getBlockchain() { + return Bitcoin.getInstance(); + } + /** * Returns Qortal AT creation bytes for cross-chain trading AT. *

@@ -590,7 +608,7 @@ public class BitcoinACCTv1 { * @param atAddress * @throws DataException */ - public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); } diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 93d3c3d7..d7f858f0 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -9,6 +9,7 @@ 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.AddressFormatException; import org.bitcoinj.core.Coin; import org.bitcoinj.core.Context; import org.bitcoinj.core.ECKey; @@ -36,7 +37,7 @@ import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; /** Bitcoin-like (Bitcoin, Litecoin, etc.) support */ -public abstract class Bitcoiny { +public abstract class Bitcoiny implements ForeignBlockchain { protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class); @@ -82,6 +83,24 @@ public abstract class Bitcoiny { return this.params; } + // Interface obligations + + @Override + public boolean isValidAddress(String address) { + try { + ScriptType addressType = Address.fromString(this.params, address).getOutputScriptType(); + + return addressType == ScriptType.P2PKH || addressType == ScriptType.P2SH; + } catch (AddressFormatException e) { + return false; + } + } + + @Override + public boolean isValidWalletKey(String walletKey) { + return this.isValidXprv(walletKey); + } + // Actual useful methods for use by other classes public String format(Coin amount) { @@ -247,9 +266,10 @@ public abstract class Bitcoiny { * @param xprv58 BIP32 private key * @param recipient P2PKH address * @param amount unscaled amount + * @param feePerByte unscaled fee per byte, or null to use default fees * @return transaction, or null if insufficient funds */ - public Transaction buildSpend(String xprv58, String recipient, long amount) { + public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) { Context.propagate(bitcoinjContext); Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); @@ -258,8 +278,11 @@ public abstract class Bitcoiny { Address destination = Address.fromString(this.params, recipient); SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); - // Allow override of default for TestNet3, etc. - sendRequest.feePerKb = this.getFeePerKb(); + if (feePerByte != null) + sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024 + else + // Allow override of default for TestNet3, etc. + sendRequest.feePerKb = this.getFeePerKb(); try { wallet.completeTx(sendRequest); @@ -269,6 +292,18 @@ public abstract class Bitcoiny { } } + /** + * Returns bitcoinj transaction sending amount to recipient using default fees. + * + * @param xprv58 BIP32 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) { + return buildSpend(xprv58, recipient, amount, null); + } + /** * Returns unspent Bitcoin balance given 'm' BIP32 key. * diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java new file mode 100644 index 00000000..0a71e9d9 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -0,0 +1,9 @@ +package org.qortal.crosschain; + +public interface ForeignBlockchain { + + public boolean isValidAddress(String address); + + public boolean isValidWalletKey(String walletKey); + +} diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java index 394d1abb..4331aef5 100644 --- a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -87,11 +87,12 @@ import com.google.common.primitives.Bytes; * * */ -public class LitecoinACCTv1 { +public class LitecoinACCTv1 implements ACCT { + + public static final String NAME = "LitcoinACCTv1"; + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes public static final int SECRET_LENGTH = 32; - public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ private static final int MODE_VALUE_OFFSET = 61; @@ -112,9 +113,26 @@ public class LitecoinACCTv1 { public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + private static LitecoinACCTv1 instance; + private LitecoinACCTv1() { } + public static synchronized LitecoinACCTv1 getInstance() { + if (instance == null) + instance = new LitecoinACCTv1(); + + return instance; + } + + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + public ForeignBlockchain getBlockchain() { + return Litecoin.getInstance(); + } + /** * Returns Qortal AT creation bytes for cross-chain trading AT. *

@@ -541,7 +559,7 @@ public class LitecoinACCTv1 { * @param atAddress * @throws DataException */ - public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); } diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java new file mode 100644 index 00000000..2cb16e90 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -0,0 +1,80 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.qortal.utils.ByteArray; +import org.qortal.utils.Triple; + +public enum SupportedBlockchain { + + BITCOIN(Arrays.asList( + Triple.valueOf(BitcoinACCTv1.NAME, BitcoinACCTv1.CODE_BYTES_HASH, BitcoinACCTv1::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Bitcoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return BitcoinACCTv1.getInstance(); + } + }, + + LITECOIN(Arrays.asList( + Triple.valueOf(LitecoinACCTv1.NAME, LitecoinACCTv1.CODE_BYTES_HASH, LitecoinACCTv1::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Litecoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return LitecoinACCTv1.getInstance(); + } + }; + + private final Map> supportedAcctsByCodeHash = new HashMap<>(); + private final Map> supportedAcctsByName = new HashMap<>(); + + SupportedBlockchain(List>> supportedAccts) { + supportedAccts.forEach(triple -> triple.consume((acctName, hashBytes, supplier) -> { + supportedAcctsByCodeHash.put(new ByteArray(hashBytes), supplier); + supportedAcctsByName.put(acctName, supplier); + })); + } + + public abstract ForeignBlockchain getInstance(); + + public abstract ACCT getLatestAcct(); + + public static ACCT getAcctByCodeHash(byte[] codeHash) { + for (SupportedBlockchain supportedBlockchain : SupportedBlockchain.values()) { + + @SuppressWarnings("unlikely-arg-type") // OK, because ByteArray is designed to work with byte[] + Supplier acctInstanceSupplier = supportedBlockchain.supportedAcctsByCodeHash.get(codeHash); + + if (acctInstanceSupplier != null) + return acctInstanceSupplier.get(); + } + + return null; + } + + public static ACCT getAcctByName(String acctName) { + for (SupportedBlockchain supportedBlockchain : SupportedBlockchain.values()) { + Supplier acctInstanceSupplier = supportedBlockchain.supportedAcctsByName.get(acctName); + + if (acctInstanceSupplier != null) + return acctInstanceSupplier.get(); + } + + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 5c9cff4b..b360c53e 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -1,10 +1,5 @@ package org.qortal.data.crosschain; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - -import java.util.Map; - import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlTransient; @@ -18,22 +13,13 @@ 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), 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); + private String acctName; + private String tradeState; - public final int value; - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - State(int value) { - this.value = value; - } - - public static State valueOf(int value) { - return map.get(value); - } - } - private State tradeState; + // Internal use - not shown via API + @XmlTransient + @Schema(hidden = true) + private int tradeStateValue; private String creatorAddress; private String atAddress; @@ -50,19 +36,25 @@ public class TradeBotData { private byte[] secret; private byte[] hashOfSecret; + private String foreignBlockchain; private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKeyHash; + @Deprecated + @Schema(description = "DEPRECATED: use foreignAmount instead", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private long bitcoinAmount; + @Schema(description = "amount in foreign blockchain currency", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long foreignAmount; + // Never expose this via API @XmlTransient @Schema(hidden = true) - private String xprv58; + private String foreignKey; private byte[] lastTransactionSignature; - private Integer lockTimeA; // Could be Bitcoin or Qortal... @@ -72,14 +64,18 @@ public class TradeBotData { /* JAXB */ } - public TradeBotData(byte[] tradePrivateKey, State tradeState, String creatorAddress, String atAddress, + public TradeBotData(byte[] tradePrivateKey, String acctName, String tradeState, int tradeStateValue, + String creatorAddress, String atAddress, long timestamp, long qortAmount, byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, byte[] secret, byte[] hashOfSecret, - byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { + String foreignBlockchain, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, + long foreignAmount, String foreignKey, + byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { this.tradePrivateKey = tradePrivateKey; + this.acctName = acctName; this.tradeState = tradeState; + this.tradeStateValue = tradeStateValue; this.creatorAddress = creatorAddress; this.atAddress = atAddress; this.timestamp = timestamp; @@ -89,10 +85,13 @@ public class TradeBotData { this.tradeNativeAddress = tradeNativeAddress; this.secret = secret; this.hashOfSecret = hashOfSecret; + this.foreignBlockchain = foreignBlockchain; this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; - this.bitcoinAmount = bitcoinAmount; - this.xprv58 = xprv58; + // deprecated copy + this.bitcoinAmount = foreignAmount; + this.foreignAmount = foreignAmount; + this.foreignKey = foreignKey; this.lastTransactionSignature = lastTransactionSignature; this.lockTimeA = lockTimeA; this.receivingAccountInfo = receivingAccountInfo; @@ -102,14 +101,26 @@ public class TradeBotData { return this.tradePrivateKey; } - public State getState() { + public String getAcctName() { + return this.acctName; + } + + public String getState() { return this.tradeState; } - public void setState(State state) { + public void setState(String state) { this.tradeState = state; } + public int getStateValue() { + return this.tradeStateValue; + } + + public void setStateValue(int stateValue) { + this.tradeStateValue = stateValue; + } + public String getCreatorAddress() { return this.creatorAddress; } @@ -154,6 +165,10 @@ public class TradeBotData { return this.hashOfSecret; } + public String getForeignBlockchain() { + return this.foreignBlockchain; + } + public byte[] getTradeForeignPublicKey() { return this.tradeForeignPublicKey; } @@ -162,12 +177,12 @@ public class TradeBotData { return this.tradeForeignPublicKeyHash; } - public long getBitcoinAmount() { - return this.bitcoinAmount; + public long getForeignAmount() { + return this.foreignAmount; } - public String getXprv58() { - return this.xprv58; + public String getForeignKey() { + return this.foreignKey; } public byte[] getLastTransactionSignature() { @@ -192,7 +207,7 @@ public class TradeBotData { // Mostly for debugging public String toString() { - return String.format("%s: %s", this.atAddress, this.tradeState.name()); + return String.format("%s: %s (%d)", this.atAddress, this.tradeState, this.tradeStateValue); } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 589ca0a4..6d962a31 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -19,12 +19,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { - String sql = "SELECT trade_state, creator_address, at_address, " + String sql = "SELECT acct_name, trade_state, trade_state_value, " + + "creator_address, at_address, " + "updated_when, qort_amount, " + "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, receiving_account_info " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates " + "WHERE trade_private_key = ?"; @@ -32,36 +33,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { 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 creatorAddress = resultSet.getString(2); - String atAddress = resultSet.getString(3); - long timestamp = resultSet.getLong(4); - long qortAmount = resultSet.getLong(5); - byte[] tradeNativePublicKey = resultSet.getBytes(6); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(7); - String tradeNativeAddress = resultSet.getString(8); - byte[] secret = resultSet.getBytes(9); - byte[] hashOfSecret = resultSet.getBytes(10); - byte[] tradeForeignPublicKey = resultSet.getBytes(11); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(12); - long bitcoinAmount = resultSet.getLong(13); - String xprv58 = resultSet.getString(14); - byte[] lastTransactionSignature = resultSet.getBytes(15); - Integer lockTimeA = resultSet.getInt(16); + String acctName = resultSet.getString(1); + String tradeState = resultSet.getString(2); + int tradeStateValue = resultSet.getInt(3); + String creatorAddress = resultSet.getString(4); + String atAddress = resultSet.getString(5); + long timestamp = resultSet.getLong(6); + long qortAmount = resultSet.getLong(7); + byte[] tradeNativePublicKey = resultSet.getBytes(8); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(9); + String tradeNativeAddress = resultSet.getString(10); + byte[] secret = resultSet.getBytes(11); + byte[] hashOfSecret = resultSet.getBytes(12); + String foreignBlockchain = resultSet.getString(13); + byte[] tradeForeignPublicKey = resultSet.getBytes(14); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(15); + long foreignAmount = resultSet.getLong(16); + String foreignKey = resultSet.getString(17); + byte[] lastTransactionSignature = resultSet.getBytes(18); + Integer lockTimeA = resultSet.getInt(19); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; - byte[] receivingAccountInfo = resultSet.getBytes(17); + byte[] receivingAccountInfo = resultSet.getBytes(20); - return new TradeBotData(tradePrivateKey, tradeState, + return new TradeBotData(tradePrivateKey, acctName, + tradeState, tradeStateValue, creatorAddress, atAddress, timestamp, qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, - tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); } catch (SQLException e) { throw new DataException("Unable to fetch trade-bot trading state from repository", e); } @@ -69,12 +70,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public List getAllTradeBotData() throws DataException { - String sql = "SELECT trade_private_key, trade_state, creator_address, at_address, " + String sql = "SELECT trade_private_key, acct_name, trade_state, trade_state_value, " + + "creator_address, at_address, " + "updated_when, qort_amount, " + "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, receiving_account_info " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -85,36 +87,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { do { byte[] tradePrivateKey = resultSet.getBytes(1); - int tradeStateValue = resultSet.getInt(2); - TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); - if (tradeState == null) - throw new DataException("Illegal trade-bot trade-state fetched from repository"); - - String creatorAddress = resultSet.getString(3); - String atAddress = resultSet.getString(4); - long timestamp = resultSet.getLong(5); - long qortAmount = resultSet.getLong(6); - byte[] tradeNativePublicKey = resultSet.getBytes(7); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(8); - String tradeNativeAddress = resultSet.getString(9); - byte[] secret = resultSet.getBytes(10); - byte[] hashOfSecret = resultSet.getBytes(11); - byte[] tradeForeignPublicKey = resultSet.getBytes(12); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(13); - long bitcoinAmount = resultSet.getLong(14); - String xprv58 = resultSet.getString(15); - byte[] lastTransactionSignature = resultSet.getBytes(16); - Integer lockTimeA = resultSet.getInt(17); + String acctName = resultSet.getString(2); + String tradeState = resultSet.getString(3); + int tradeStateValue = resultSet.getInt(4); + String creatorAddress = resultSet.getString(5); + String atAddress = resultSet.getString(6); + long timestamp = resultSet.getLong(7); + long qortAmount = resultSet.getLong(8); + byte[] tradeNativePublicKey = resultSet.getBytes(9); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(10); + String tradeNativeAddress = resultSet.getString(11); + byte[] secret = resultSet.getBytes(12); + byte[] hashOfSecret = resultSet.getBytes(13); + String foreignBlockchain = resultSet.getString(14); + byte[] tradeForeignPublicKey = resultSet.getBytes(15); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(16); + long foreignAmount = resultSet.getLong(17); + String foreignKey = resultSet.getString(18); + byte[] lastTransactionSignature = resultSet.getBytes(19); + Integer lockTimeA = resultSet.getInt(20); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; - byte[] receivingAccountInfo = resultSet.getBytes(18); + byte[] receivingAccountInfo = resultSet.getBytes(21); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acctName, + tradeState, tradeStateValue, creatorAddress, atAddress, timestamp, qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, - tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -129,7 +131,9 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates"); saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) - .bind("trade_state", tradeBotData.getState().value) + .bind("acct_name", tradeBotData.getAcctName()) + .bind("trade_state", tradeBotData.getState()) + .bind("trade_state_value", tradeBotData.getStateValue()) .bind("creator_address", tradeBotData.getCreatorAddress()) .bind("at_address", tradeBotData.getAtAddress()) .bind("updated_when", tradeBotData.getTimestamp()) @@ -137,11 +141,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { .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("secret", tradeBotData.getSecret()) + .bind("hash_of_secret", tradeBotData.getHashOfSecret()) + .bind("foreign_blockchain", tradeBotData.getForeignBlockchain()) .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) - .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) - .bind("xprv58", tradeBotData.getXprv58()) + .bind("foreign_amount", tradeBotData.getForeignAmount()) + .bind("foreign_key", tradeBotData.getForeignKey()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) .bind("locktime_a", tradeBotData.getLockTimeA()) .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c6356e5d..26cba152 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -4,9 +4,12 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.Arrays; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot; public class HSQLDBDatabaseUpdates { @@ -609,6 +612,7 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot + // See case 25 below for changes stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " @@ -653,6 +657,35 @@ public class HSQLDBDatabaseUpdates { stmt.execute("DROP TABLE IF EXISTS NextBlockHeight"); break; + case 25: + // Multiple blockchains, ACCTs and trade-bots + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN acct_name VARCHAR(40) BEFORE trade_state"); + stmt.execute("UPDATE TradeBotStates SET acct_name = 'BitcoinACCTv1' WHERE acct_name IS NULL"); + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN acct_name SET NOT NULL"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state RENAME TO trade_state_value"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN trade_state VARCHAR(40) BEFORE trade_state_value"); + // Any existing values will be BitcoinACCTv1 + StringBuilder updateTradeBotStatesSql = new StringBuilder(1024); + updateTradeBotStatesSql.append("UPDATE TradeBotStates SET (trade_state) = (") + .append("SELECT state_name FROM (VALUES ") + .append( + Arrays.stream(BitcoinACCTv1TradeBot.State.values()) + .map(state -> String.format("(%d, '%s')", state.value, state.name())) + .collect(Collectors.joining(", "))) + .append(") AS BitcoinACCTv1States (state_value, state_name) ") + .append("WHERE state_value = trade_state_value)"); + stmt.execute(updateTradeBotStatesSql.toString()); + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state SET NOT NULL"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN foreign_blockchain VARCHAR(40) BEFORE trade_foreign_public_key"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN bitcoin_amount RENAME TO foreign_amount"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN xprv58 RENAME TO foreign_key"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/utils/Triple.java b/src/main/java/org/qortal/utils/Triple.java index 5095a2da..0b9757ee 100644 --- a/src/main/java/org/qortal/utils/Triple.java +++ b/src/main/java/org/qortal/utils/Triple.java @@ -1,42 +1,55 @@ package org.qortal.utils; -public class Triple { +public class Triple { - private T a; - private U b; - private V c; + @FunctionalInterface + public interface TripleConsumer { + public void accept(A a, B b, C c); + } + + private A a; + private B b; + private C c; + + public static Triple valueOf(A a, B b, C c) { + return new Triple<>(a, b, c); + } public Triple() { } - public Triple(T a, U b, V c) { + public Triple(A a, B b, C c) { this.a = a; this.b = b; this.c = c; } - public void setA(T a) { + public void setA(A a) { this.a = a; } - public T getA() { + public A getA() { return a; } - public void setB(U b) { + public void setB(B b) { this.b = b; } - public U getB() { + public B getB() { return b; } - public void setC(V c) { + public void setC(C c) { this.c = c; } - public V getC() { + public C getC() { return c; } + public void consume(TripleConsumer consumer) { + consumer.accept(this.a, this.b, this.c); + } + } diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java index 98917eb9..87b5b03e 100644 --- a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java @@ -153,7 +153,7 @@ public class BitcoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.CANCELLED, tradeData.mode); // Check balances @@ -212,7 +212,7 @@ public class BitcoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.CANCELLED, tradeData.mode); } } @@ -250,7 +250,7 @@ public class BitcoinACCTv1Tests extends Common { describeAt(repository, atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); // AT should be in TRADE mode assertEquals(AcctMode.TRADING, tradeData.mode); @@ -312,7 +312,7 @@ public class BitcoinACCTv1Tests extends Common { describeAt(repository, atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); // AT should still be in OFFER mode assertEquals(AcctMode.OFFERING, tradeData.mode); @@ -359,7 +359,7 @@ public class BitcoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.REFUNDED, tradeData.mode); // Test orphaning @@ -415,7 +415,7 @@ public class BitcoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.REDEEMED, tradeData.mode); // Check balances @@ -486,7 +486,7 @@ public class BitcoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); // Check balances @@ -546,7 +546,7 @@ public class BitcoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); @@ -568,7 +568,7 @@ public class BitcoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); // Check balances @@ -624,7 +624,7 @@ public class BitcoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should be in TRADING mode - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); } } @@ -747,7 +747,7 @@ public class BitcoinACCTv1Tests extends Common { private void describeAt(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java index 71156902..1b2443d4 100644 --- a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java @@ -151,7 +151,7 @@ public class LitecoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.CANCELLED, tradeData.mode); // Check balances @@ -210,7 +210,7 @@ public class LitecoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.CANCELLED, tradeData.mode); } } @@ -248,7 +248,7 @@ public class LitecoinACCTv1Tests extends Common { describeAt(repository, atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); // AT should be in TRADE mode assertEquals(AcctMode.TRADING, tradeData.mode); @@ -310,7 +310,7 @@ public class LitecoinACCTv1Tests extends Common { describeAt(repository, atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); // AT should still be in OFFER mode assertEquals(AcctMode.OFFERING, tradeData.mode); @@ -357,7 +357,7 @@ public class LitecoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REFUNDED mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.REFUNDED, tradeData.mode); // Test orphaning @@ -413,7 +413,7 @@ public class LitecoinACCTv1Tests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REDEEMED mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.REDEEMED, tradeData.mode); // Check balances @@ -484,7 +484,7 @@ public class LitecoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); // Check balances @@ -544,7 +544,7 @@ public class LitecoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); @@ -599,7 +599,7 @@ public class LitecoinACCTv1Tests extends Common { assertFalse(atData.getIsFinished()); // AT should be in TRADING mode - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); assertEquals(AcctMode.TRADING, tradeData.mode); } } @@ -722,7 +722,7 @@ public class LitecoinACCTv1Tests extends Common { private void describeAt(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight();