diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar similarity index 78% rename from lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar rename to lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar index 5abe2c77..45045ad3 100644 Binary files a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar and b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar differ diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom similarity index 94% rename from lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom rename to lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom index 39af6ac7..b24b6706 100644 --- a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom +++ b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom @@ -4,6 +4,6 @@ 4.0.0 org.ciyam AT - 1.3.4 + 1.3.5 POM was created from install:install-file diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 2cf6d13a..82cd311a 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,10 +3,11 @@ org.ciyam AT - 1.3.4 + 1.3.5 1.3.4 + 1.3.5 - 20200414162728 + 20200717104214 diff --git a/pom.xml b/pom.xml index 26c32df2..43947614 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ 0.15.5 1.64 ${maven.build.timestamp} - 1.3.4 + 1.3.5 3.6 1.8 1.2.2 diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index b88edb5a..25966fa6 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -43,6 +43,8 @@ import org.qortal.api.websocket.ActiveChatsWebSocket; import org.qortal.api.websocket.AdminStatusWebSocket; import org.qortal.api.websocket.BlocksWebSocket; import org.qortal.api.websocket.ChatMessagesWebSocket; +import org.qortal.api.websocket.TradeBotWebSocket; +import org.qortal.api.websocket.TradeOffersWebSocket; import org.qortal.settings.Settings; public class ApiService { @@ -196,6 +198,8 @@ public class ApiService { context.addServlet(BlocksWebSocket.class, "/websockets/blocks"); context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*"); context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); + context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers"); + context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot"); // Start server this.server.start(); diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java index 5e95e36c..074fd24d 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java @@ -25,6 +25,9 @@ public class CrossChainBitcoinRedeemRequest { @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") public byte[] secret; + @Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf") + public byte[] receivingAccountInfo; + public CrossChainBitcoinRedeemRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index c4fa097a..e8d38703 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -12,20 +12,19 @@ public class CrossChainBuildRequest { @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] creatorPublicKey; - @Schema(description = "Initial QORT amount paid when trade agreed", example = "0.00100000") - @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - public long initialQortAmount; - @Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - public long finalQortAmount; + public long qortAmount; @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fundingQortAmount; + @Schema(description = "HASH160 of creator's Bitcoin public key", example = "2daMveGc5pdjRyFacbxBzMksCbyC") + public byte[] bitcoinPublicKeyHash; + @Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV") - public byte[] secretHash; + public byte[] hashOfSecretB; @Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java index e1f57a7e..25a18952 100644 --- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -8,10 +8,10 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainCancelRequest { - @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + @Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] creatorPublicKey; - @Schema(description = "Qortal AT address") + @Schema(description = "Qortal trade AT address") public String atAddress; public CrossChainCancelRequest() { diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java new file mode 100644 index 00000000..4cabfc37 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -0,0 +1,86 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.crosschain.BTCACCT; +import org.qortal.data.crosschain.CrossChainTradeData; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainOfferSummary { + + // Properties + + @Schema(description = "AT's Qortal address") + public String qortalAtAddress; + + @Schema(description = "AT creator's Qortal address") + public String qortalCreator; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long btcAmount; + + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + private int tradeTimeout; + + private BTCACCT.Mode mode; + + private long timestamp; + + private String partnerQortalReceivingAddress; + + protected CrossChainOfferSummary() { + /* For JAXB */ + } + + public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) { + this.qortalAtAddress = crossChainTradeData.qortalAtAddress; + this.qortalCreator = crossChainTradeData.qortalCreator; + this.qortAmount = crossChainTradeData.qortAmount; + this.btcAmount = crossChainTradeData.expectedBitcoin; + this.tradeTimeout = crossChainTradeData.tradeTimeout; + this.mode = crossChainTradeData.mode; + this.timestamp = timestamp; + this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; + } + + public String getQortalAtAddress() { + return this.qortalAtAddress; + } + + public String getQortalCreator() { + return this.qortalCreator; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public long getBtcAmount() { + return this.btcAmount; + } + + public int getTradeTimeout() { + return this.tradeTimeout; + } + + public BTCACCT.Mode getMode() { + return this.mode; + } + + public long getTimestamp() { + return this.timestamp; + } + + public String getPartnerQortalReceivingAddress() { + return this.partnerQortalReceivingAddress; + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 99820022..7ad825d4 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -8,14 +8,20 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainSecretRequest { - @Schema(description = "Public key to match AT's 'recipient'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") - public byte[] recipientPublicKey; + @Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] partnerPublicKey; @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") - public byte[] secret; + @Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") + public byte[] secretA; + + @Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") + public byte[] secretB; + + @Schema(description = "Qortal address for receiving QORT from AT") + public String receivingAddress; public CrossChainSecretRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java index 32737dd5..1afd7290 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -8,14 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeRequest { - @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") - public byte[] creatorPublicKey; + @Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] tradePublicKey; @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "Qortal address for trade partner/recipient") - public String recipient; + @Schema(description = "Signature of trading partner's 'offer' MESSAGE transaction") + public byte[] messageTransactionSignature; public CrossChainTradeRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java new file mode 100644 index 00000000..52ac7de3 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java @@ -0,0 +1,43 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.data.crosschain.CrossChainTradeData; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeSummary { + + private long tradeTimestamp; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long btcAmount; + + protected CrossChainTradeSummary() { + /* For JAXB */ + } + + public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) { + this.tradeTimestamp = timestamp; + this.qortAmount = crossChainTradeData.qortAmount; + this.btcAmount = crossChainTradeData.expectedBitcoin; + } + + public long getTradeTimestamp() { + return this.tradeTimestamp; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public long getBtcAmount() { + return this.btcAmount; + } + +} diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java new file mode 100644 index 00000000..adc319e3 --- /dev/null +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -0,0 +1,36 @@ +package org.qortal.api.model; + +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 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") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long qortAmount; + + @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long fundingQortAmount; + + @Schema(description = "Bitcoin amount wanted in return", example = "0.00864200") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long bitcoinAmount; + + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + public int tradeTimeout; + + @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx") + 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/TradeBotRespondRequest.java new file mode 100644 index 00000000..4e947a9b --- /dev/null +++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java @@ -0,0 +1,23 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotRespondRequest { + + @Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy") + public String atAddress; + + @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") + public String xprv58; + + @Schema(description = "Qortal address for receiving QORT from AT") + public String receivingAddress; + + public TradeBotRespondRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index f60deb23..6901af06 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -13,6 +13,9 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; 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; @@ -24,31 +27,40 @@ 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.ApiException; import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.api.model.CrossChainTradeSummary; +import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.model.TradeBotRespondRequest; import org.qortal.api.model.CrossChainBitcoinP2SHStatus; import org.qortal.api.model.CrossChainBitcoinRedeemRequest; import org.qortal.api.model.CrossChainBitcoinRefundRequest; 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.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; 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.CrossChainTradeData.Mode; +import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; @@ -60,6 +72,7 @@ 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; @@ -68,8 +81,6 @@ import org.qortal.transform.transaction.MessageTransactionTransformer; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -import com.google.common.primitives.Bytes; - @Path("/crosschain") @Tag(name = "Cross-Chain") public class CrossChainResource { @@ -83,7 +94,6 @@ public class CrossChainResource { summary = "Find cross-chain trade offers", responses = { @ApiResponse( - description = "automated transactions", content = @Content( array = @ArraySchema( schema = @Schema( @@ -116,8 +126,6 @@ public class CrossChainResource { } return crossChainTradesData; - } catch (ApiException e) { - throw e; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -145,12 +153,14 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) public String buildTrade(CrossChainBuildRequest tradeRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - if (tradeRequest.secretHash == null || tradeRequest.secretHash.length != BTC.HASH160_LENGTH) + if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != BTC.HASH160_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.tradeTimeout == null) @@ -159,17 +169,14 @@ public class CrossChainResource { if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - if (tradeRequest.initialQortAmount < 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (tradeRequest.finalQortAmount <= 0) + if (tradeRequest.qortAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.fundingQortAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); // funding amount must exceed initial + final - if (tradeRequest.fundingQortAmount <= tradeRequest.initialQortAmount + tradeRequest.finalQortAmount) + if (tradeRequest.fundingQortAmount <= tradeRequest.qortAmount) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.bitcoinAmount <= 0) @@ -178,7 +185,8 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); - byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.secretHash, tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, + tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); @@ -213,12 +221,12 @@ public class CrossChainResource { } @POST - @Path("/tradeoffer/recipient") + @Path("/tradeoffer/trademessage") @Operation( - summary = "Builds raw, unsigned 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 address of Qortal recipient.
" - + "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 same account as the AT creator otherwise the MESSAGE transaction will be invalid.", + 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( @@ -239,29 +247,55 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { - byte[] creatorPublicKey = tradeRequest.creatorPublicKey; + public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { + Security.checkApiCallAllowed(request); - if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + 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.recipient == null || !Crypto.isValidAddress(tradeRequest.recipient)) + 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, creatorPublicKey, tradeRequest.atAddress); + ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + if (crossChainTradeData.mode != BTCACCT.Mode.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(); + BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); + if (offerMessageData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + // Good to make MESSAGE - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0); - byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes); + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -270,12 +304,12 @@ public class CrossChainResource { } @POST - @Path("/tradeoffer/secret") + @Path("/tradeoffer/redeemmessage") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient", - description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.
" - + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" - + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.", + 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( @@ -296,35 +330,43 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String sendSecret(CrossChainSecretRequest secretRequest) { - byte[] recipientPublicKey = secretRequest.recipientPublicKey; + public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); - if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + 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.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH) + if (secretRequest.secretA == null || secretRequest.secretA.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + if (secretRequest.secretB == null || secretRequest.secretB.length != BTCACCT.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, null, secretRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER) + if (crossChainTradeData.mode != BTCACCT.Mode.TRADING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey); - String recipientAddress = recipientAccount.getAddress(); + String partnerAddress = Crypto.toAddress(partnerPublicKey); - // MESSAGE must come from address that AT considers trade partner / 'recipient' - if (!crossChainTradeData.qortalRecipient.equals(recipientAddress)) + // 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[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret); + byte[] messageData = BTCACCT.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress); + byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -335,10 +377,10 @@ public class CrossChainResource { @DELETE @Path("/tradeoffer") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer", + 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 same account as the AT creator otherwise the MESSAGE transaction will be invalid.", + + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, content = @Content( @@ -359,7 +401,9 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { + public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -369,19 +413,22 @@ public class CrossChainResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress); + ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + if (crossChainTradeData.mode != BTCACCT.Mode.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 - PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); - String creatorAddress = creatorAccount.getAddress(); - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0); + String atCreatorAddress = Crypto.toAddress(creatorPublicKey); + byte[] messageData = BTCACCT.buildCancelMessage(atCreatorAddress); - byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes); + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -390,9 +437,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh") + @Path("/p2sh/a") @Operation( - summary = "Returns Bitcoin P2SH address based on trade info", + summary = "Returns Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -409,7 +456,39 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + 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) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -424,13 +503,13 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -441,9 +520,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/check") + @Path("/p2sh/a/check") @Operation( - summary = "Checks Bitcoin P2SH address based on trade info", + summary = "Checks Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -460,7 +539,39 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + public CrossChainBitcoinP2SHStatus 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 = CrossChainBitcoinP2SHStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -475,13 +586,16 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -494,25 +608,25 @@ public class CrossChainResource { // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus(); p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); - p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8); + p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { + if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { p2shStatus.canRedeem = now >= medianBlockTime * 1000L; - p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; + p2shStatus.canRefund = now >= lockTime * 1000L; } if (now >= medianBlockTime * 1000L) { // See if we can extract secret List rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress); - p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); + p2shStatus.secret = BTCP2SH.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); } return p2shStatus; @@ -522,9 +636,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/refund") + @Path("/p2sh/a/refund") @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address", + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address", requestBody = @RequestBody( required = true, content = @Content( @@ -542,7 +656,40 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) { + 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) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -572,13 +719,16 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, refundRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -587,7 +737,7 @@ public class CrossChainResource { // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); @@ -595,16 +745,16 @@ public class CrossChainResource { if (fundingOutputs.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - boolean canRefund = now >= crossChainTradeData.lockTime * 1000L; + boolean canRefund = now >= lockTime * 1000L; if (!canRefund) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - if (p2shBalance.value < crossChainTradeData.expectedBitcoin) + if (p2shBalance < crossChainTradeData.expectedBitcoin) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - Coin refundAmount = p2shBalance.subtract(Coin.valueOf(refundRequest.bitcoinMinerFee.unscaledValue().longValue())); + Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); + org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); if (!wasBroadcast) @@ -617,9 +767,10 @@ public class CrossChainResource { } @POST - @Path("/p2sh/redeem") + @Path("/p2sh/a/redeem") @Operation( - summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address", + 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( @@ -637,7 +788,41 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) { + 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) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -668,15 +853,24 @@ public class CrossChainResource { if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.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, null, redeemRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -688,11 +882,11 @@ public class CrossChainResource { long now = NTP.getTime(); // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); - if (p2shBalance.value < crossChainTradeData.expectedBitcoin) + if (p2shBalance < crossChainTradeData.expectedBitcoin) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -703,9 +897,9 @@ public class CrossChainResource { if (!canRedeem) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - Coin redeemAmount = p2shBalance.subtract(Coin.valueOf(redeemRequest.bitcoinMinerFee.unscaledValue().longValue())); + Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret); + org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); if (!wasBroadcast) @@ -717,15 +911,305 @@ public class CrossChainResource { } } - private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException { + @POST + @Path("/btc/walletbalance") + @Operation( + summary = "Returns BTC balance for BIP32 wallet", + description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private key in base58", + example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) + public String getBitcoinWalletBalance(String xprv58) { + Security.checkApiCallAllowed(request); + + if (!BTC.getInstance().isValidXprv(xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = BTC.getInstance().getWalletBalance(xprv58); + if (balance == null) + return "null"; + + return balance.toString(); + } + + @GET + @Path("/tradebot") + @Operation( + 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(BTC.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 (!BTC.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 = BTCACCT.populateTradeData(repository, atData); + + if (crossChainTradeData.mode != BTCACCT.Mode.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( + summary = "Find completed cross-chain trades", + description = "Returns summary info about successfully completed cross-chain trades", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeSummary.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public List getCompletedTrades( + @Parameter( ref = "limit") @QueryParam("limit") Integer limit, + @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, + @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { + // Impose a limit on 'limit' + if (limit != null && limit > 100) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + final Boolean isFinished = Boolean.TRUE; + final Integer minimumFinalHeight = null; + + try (final Repository repository = RepositoryManager.getRepository()) { + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, + BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value, + minimumFinalHeight, + limit, offset, reverse); + + List crossChainTrades = new ArrayList<>(); + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); + crossChainTrades.add(crossChainTradeSummary); + } + + return crossChainTrades; + } 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); - // Does supplied public key match that of AT? - if (creatorPublicKey != null && !Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - // Must be correct AT - check functionality using code hash if (!Arrays.equals(atData.getCodeHash(), BTCACCT.CODE_BYTES_HASH)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -738,27 +1222,36 @@ public class CrossChainResource { } private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { - PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, senderPublicKey); - long txTimestamp = NTP.getTime(); - byte[] lastReference = creatorAccount.getLastReference(); - if (lastReference == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE); + // 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 = null; + 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); - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); + if (requiresPoW) { + messageTransaction.computeNonce(); + } else { + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + } ValidationResult result = messageTransaction.isValidUnconfirmed(); if (result != ValidationResult.OK) diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index a276dba7..1f541e36 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -12,7 +12,6 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; 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.WebSocketServlet; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.controller.ChatNotifier; import org.qortal.crypto.Crypto; @@ -24,7 +23,7 @@ import org.qortal.repository.RepositoryManager; @WebSocket @SuppressWarnings("serial") -public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSocket { +public class ActiveChatsWebSocket extends ApiWebSocket { @Override public void configure(WebSocketServletFactory factory) { @@ -33,7 +32,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock @OnWebSocketConnect public void onWebSocketConnect(Session session) { - Map pathParams = this.getPathParams(session, "/{address}"); + Map pathParams = getPathParams(session, "/{address}"); String address = pathParams.get("address"); if (address == null || !Crypto.isValidAddress(address)) { @@ -76,7 +75,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock StringWriter stringWriter = new StringWriter(); - this.marshall(stringWriter, activeChats); + marshall(stringWriter, activeChats); // Only output if something has changed String output = stringWriter.toString(); diff --git a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java index ff42be2e..173a3abf 100644 --- a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java @@ -11,7 +11,6 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; 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.WebSocketServlet; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.api.model.NodeStatus; import org.qortal.controller.StatusNotifier; @@ -21,7 +20,7 @@ import org.qortal.repository.RepositoryManager; @WebSocket @SuppressWarnings("serial") -public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSocket { +public class AdminStatusWebSocket extends ApiWebSocket { @Override public void configure(WebSocketServletFactory factory) { @@ -57,7 +56,7 @@ public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSock StringWriter stringWriter = new StringWriter(); - this.marshall(stringWriter, nodeStatus); + marshall(stringWriter, nodeStatus); // Only output if something has changed String output = stringWriter.toString(); diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java index 9209c5b9..87ee16cd 100644 --- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java @@ -3,7 +3,10 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.List; import java.util.Map; import javax.xml.bind.JAXBContext; @@ -13,24 +16,28 @@ import javax.xml.bind.Marshaller; import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; import org.eclipse.persistence.jaxb.JAXBContextFactory; import org.eclipse.persistence.jaxb.MarshallerProperties; import org.qortal.api.ApiError; import org.qortal.api.ApiErrorRoot; -interface ApiWebSocket { +@SuppressWarnings("serial") +abstract class ApiWebSocket extends WebSocketServlet { - default String getPathInfo(Session session) { + private static final Map, List> SESSIONS_BY_CLASS = new HashMap<>(); + + protected static String getPathInfo(Session session) { ServletUpgradeRequest upgradeRequest = (ServletUpgradeRequest) session.getUpgradeRequest(); return upgradeRequest.getHttpServletRequest().getPathInfo(); } - default Map getPathParams(Session session, String pathSpec) { + protected static Map getPathParams(Session session, String pathSpec) { UriTemplatePathSpec uriTemplatePathSpec = new UriTemplatePathSpec(pathSpec); - return uriTemplatePathSpec.getPathParams(this.getPathInfo(session)); + return uriTemplatePathSpec.getPathParams(getPathInfo(session)); } - default void sendError(Session session, ApiError apiError) { + protected static void sendError(Session session, ApiError apiError) { ApiErrorRoot apiErrorRoot = new ApiErrorRoot(); apiErrorRoot.setApiError(apiError); @@ -43,7 +50,7 @@ interface ApiWebSocket { } } - default void marshall(Writer writer, Object object) throws IOException { + protected static void marshall(Writer writer, Object object) throws IOException { Marshaller marshaller = createMarshaller(object.getClass()); try { @@ -53,7 +60,7 @@ interface ApiWebSocket { } } - default void marshall(Writer writer, Collection collection) throws IOException { + protected static void marshall(Writer writer, Collection collection) throws IOException { // If collection is empty then we're returning "[]" anyway if (collection.isEmpty()) { writer.append("[]"); @@ -92,4 +99,22 @@ interface ApiWebSocket { } } + public void onWebSocketConnect(Session session) { + synchronized (SESSIONS_BY_CLASS) { + SESSIONS_BY_CLASS.computeIfAbsent(this.getClass(), clazz -> new ArrayList<>()).add(session); + } + } + + public void onWebSocketClose(Session session, int statusCode, String reason) { + synchronized (SESSIONS_BY_CLASS) { + SESSIONS_BY_CLASS.get(this.getClass()).remove(session); + } + } + + protected List getSessions() { + synchronized (SESSIONS_BY_CLASS) { + return new ArrayList<>(SESSIONS_BY_CLASS.get(this.getClass())); + } + } + } diff --git a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java index cc5a0fbf..46a5fd84 100644 --- a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java @@ -11,7 +11,6 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; 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.WebSocketServlet; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.api.ApiError; import org.qortal.api.model.BlockInfo; @@ -23,7 +22,7 @@ import org.qortal.utils.Base58; @WebSocket @SuppressWarnings("serial") -public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket { +public class BlocksWebSocket extends ApiWebSocket { @Override public void configure(WebSocketServletFactory factory) { @@ -111,7 +110,7 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket { StringWriter stringWriter = new StringWriter(); try { - this.marshall(stringWriter, blockInfo); + marshall(stringWriter, blockInfo); session.getRemote().sendStringByFuture(stringWriter.toString()); } catch (IOException | WebSocketException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index c7244cda..beaa9ad5 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -14,7 +14,6 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; 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.WebSocketServlet; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.qortal.controller.ChatNotifier; import org.qortal.data.chat.ChatMessage; @@ -25,7 +24,7 @@ import org.qortal.repository.RepositoryManager; @WebSocket @SuppressWarnings("serial") -public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSocket { +public class ChatMessagesWebSocket extends ApiWebSocket { @Override public void configure(WebSocketServletFactory factory) { @@ -129,7 +128,7 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc StringWriter stringWriter = new StringWriter(); try { - this.marshall(stringWriter, chatMessages); + marshall(stringWriter, chatMessages); session.getRemote().sendStringByFuture(stringWriter.toString()); } catch (IOException | WebSocketException e) { diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java new file mode 100644 index 00000000..e97e54bc --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -0,0 +1,119 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +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.data.crosschain.TradeBotData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@WebSocket +@SuppressWarnings("serial") +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<>(); + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeBotWebSocket.class); + + try (final Repository repository = RepositoryManager.getRepository()) { + List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData(); + if (tradeBotEntries == null) + // How do we properly fail here? + return; + + PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState))); + } catch (DataException e) { + // No output this time + } + + EventBus.INSTANCE.addListener(this::listen); + } + + @Override + public void listen(Event event) { + if (!(event instanceof TradeBot.StateChangeEvent)) + return; + + TradeBotData tradeBotData = ((TradeBot.StateChangeEvent) event).getTradeBotData(); + String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey()); + + synchronized (PREVIOUS_STATES) { + if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState()) + // Not changed + return; + + PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState()); + } + + List tradeBotEntries = Collections.singletonList(tradeBotData); + for (Session session : getSessions()) + sendEntries(session, tradeBotEntries); + } + + @OnWebSocketConnect + public void onWebSocketConnect(Session session) { + // Send all known trade-bot entries + try (final Repository repository = RepositoryManager.getRepository()) { + List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData(); + if (tradeBotEntries == null) { + session.close(4001, "repository issue fetching trade-bot entries"); + return; + } + + if (!sendEntries(session, tradeBotEntries)) { + session.close(4002, "websocket issue"); + return; + } + } catch (DataException e) { + // No output this time + } + + super.onWebSocketConnect(session); + } + + @OnWebSocketClose + public void onWebSocketClose(Session session, int statusCode, String reason) { + super.onWebSocketClose(session, statusCode, reason); + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + /* ignored */ + } + + private boolean sendEntries(Session session, List tradeBotEntries) { + try { + StringWriter stringWriter = new StringWriter(); + marshall(stringWriter, tradeBotEntries); + + String output = stringWriter.toString(); + session.getRemote().sendStringByFuture(output); + } catch (IOException e) { + // No output this time? + return false; + } + + return true; + } + +} diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java new file mode 100644 index 00000000..2520b1d8 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -0,0 +1,212 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +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.api.model.CrossChainOfferSummary; +import org.qortal.controller.BlockNotifier; +import org.qortal.crosschain.BTCACCT; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.NTP; + +@WebSocket +@SuppressWarnings("serial") +public class TradeOffersWebSocket extends ApiWebSocket { + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeOffersWebSocket.class); + } + + @OnWebSocketConnect + public void onWebSocketConnect(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + + final boolean includeHistoric = queryParams.get("includeHistoric") != null; + final Map previousAtModes = new HashMap<>(); + List crossChainOfferSummaries; + + try (final Repository repository = RepositoryManager.getRepository()) { + List initialAtStates; + + // We want ALL OFFERING trades + Boolean isFinished = Boolean.FALSE; + Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET; + Long expectedValue = (long) BTCACCT.Mode.OFFERING.value; + Integer minimumFinalHeight = null; + + initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (initialAtStates == null) { + session.close(4001, "repository issue fetching OFFERING trades"); + return; + } + + // Save initial AT modes + previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING))); + + // Convert to offer summaries + crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null); + + if (includeHistoric) { + // We also want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours + long timestamp = NTP.getTime() - 24 * 60 * 60 * 1000L; + minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp); + + if (minimumFinalHeight != 0) { + isFinished = Boolean.TRUE; + dataByteOffset = null; + expectedValue = null; + ++minimumFinalHeight; // because height is just *before* timestamp + + List historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (historicAtStates == null) { + session.close(4002, "repository issue fetching historic trades"); + return; + } + + for (ATStateData historicAtState : historicAtStates) { + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null); + + switch (historicOfferSummary.getMode()) { + case REDEEMED: + case REFUNDED: + case CANCELLED: + break; + + default: + continue; + } + + // Add summary to initial burst + crossChainOfferSummaries.add(historicOfferSummary); + + // Save initial AT mode + previousAtModes.put(historicAtState.getATAddress(), historicOfferSummary.getMode()); + } + } + } + + } catch (DataException e) { + session.close(4003, "generic repository issue"); + return; + } + + if (!sendOfferSummaries(session, crossChainOfferSummaries)) { + session.close(4004, "websocket issue"); + return; + } + + BlockNotifier.Listener listener = blockData -> onNotify(session, blockData, previousAtModes); + BlockNotifier.getInstance().register(session, listener); + } + + @OnWebSocketClose + public void onWebSocketClose(Session session, int statusCode, String reason) { + BlockNotifier.getInstance().deregister(session); + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + /* ignored */ + } + + private void onNotify(Session session, BlockData blockData, final Map previousAtModes) { + List crossChainOfferSummaries = null; + + try (final Repository repository = RepositoryManager.getRepository()) { + // Find any new trade ATs since this block + final Boolean isFinished = null; + final Integer dataByteOffset = null; + final Long expectedValue = null; + final Integer minimumFinalHeight = blockData.getHeight(); + + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (atStates == null) + return; + + crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp()); + } catch (DataException e) { + // No output this time + } + + synchronized (previousAtModes) { //NOSONAR squid:S2445 suppressed because previousAtModes is final and curried in lambda + // Remove any entries unchanged from last time + crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode()); + + // Don't send anything if no results + if (crossChainOfferSummaries.isEmpty()) + return; + + final boolean wasSent = sendOfferSummaries(session, crossChainOfferSummaries); + + if (!wasSent) + return; + + previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode))); + } + } + + private boolean sendOfferSummaries(Session session, List crossChainOfferSummaries) { + try { + StringWriter stringWriter = new StringWriter(); + marshall(stringWriter, crossChainOfferSummaries); + + String output = stringWriter.toString(); + session.getRemote().sendStringByFuture(output); + } catch (IOException e) { + // No output this time? + return false; + } + + return true; + } + + private static CrossChainOfferSummary produceSummary(Repository repository, ATStateData atState, Long timestamp) throws DataException { + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); + + long atStateTimestamp; + + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING) + // We want when trade was created, not when it was last updated + atStateTimestamp = atState.getCreation(); + else + atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp); + } + + private static List produceSummaries(Repository repository, List atStates, Long timestamp) throws DataException { + List offerSummaries = new ArrayList<>(); + + for (ATStateData atState : atStates) + offerSummaries.add(produceSummary(repository, atState, timestamp)); + + return offerSummaries; + } + +} diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index bf7d2abc..582b44e2 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -17,7 +17,6 @@ import org.qortal.account.Account; import org.qortal.account.NullAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.asset.Asset; -import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.CiyamAtSettings; import org.qortal.crypto.Crypto; @@ -30,13 +29,13 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.PaymentTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; -import org.qortal.repository.BlockRepository; +import org.qortal.repository.ATRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.transaction.AtTransaction; -import org.qortal.transaction.Transaction; import org.qortal.transaction.Transaction.TransactionType; import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; import com.google.common.primitives.Bytes; @@ -133,9 +132,9 @@ public class QortalATAPI extends API { byte[] signature = blockSummaries.get(0).getSignature(); // Save some of minter's signature and transactions signature, so middle 24 bytes of the full 128 byte signature. - this.setA2(state, fromBytes(signature, 52)); - this.setA3(state, fromBytes(signature, 60)); - this.setA4(state, fromBytes(signature, 68)); + this.setA2(state, BitTwiddling.longFromBEBytes(signature, 52)); + this.setA3(state, BitTwiddling.longFromBEBytes(signature, 60)); + this.setA4(state, BitTwiddling.longFromBEBytes(signature, 68)); } catch (DataException e) { throw new RuntimeException("AT API unable to fetch previous block?", e); } @@ -149,59 +148,27 @@ public class QortalATAPI extends API { int height = timestamp.blockHeight; int sequence = timestamp.transactionSequence + 1; - BlockRepository blockRepository = this.getRepository().getBlockRepository(); - + ATRepository.NextTransactionInfo nextTransactionInfo; try { - int currentHeight = blockRepository.getBlockchainHeight(); - List blockTransactions = null; - - while (height <= currentHeight) { - if (blockTransactions == null) { - BlockData blockData = blockRepository.fromHeight(height); - - if (blockData == null) - throw new DataException("Unable to fetch block " + height + " from repository?"); - - Block block = new Block(this.getRepository(), blockData); - - blockTransactions = block.getTransactions(); - } - - // No more transactions in this block? Try next block - if (sequence >= blockTransactions.size()) { - ++height; - sequence = 0; - blockTransactions = null; - continue; - } - - Transaction transaction = blockTransactions.get(sequence); - - // Transaction needs to be sent to specified recipient - List recipientAddresses = transaction.getRecipientAddresses(); - if (recipientAddresses.contains(atAddress)) { - // Found a transaction - - this.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue()); - - // Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction - byte[] signature = transaction.getTransactionData().getSignature(); - this.setA2(state, fromBytes(signature, 8)); - this.setA3(state, fromBytes(signature, 16)); - this.setA4(state, fromBytes(signature, 24)); - - return; - } - - // Transaction wasn't for us - keep going - ++sequence; - } - - // No more transactions - zero A and exit - this.zeroA(state); + nextTransactionInfo = this.getRepository().getATRepository().findNextTransaction(atAddress, height, sequence); } catch (DataException e) { throw new RuntimeException("AT API unable to fetch next transaction?", e); } + + if (nextTransactionInfo == null) { + // No more transactions for AT at this time - zero A and exit + this.zeroA(state); + return; + } + + // Found a transaction + + this.setA1(state, new Timestamp(nextTransactionInfo.height, timestamp.blockchainId, nextTransactionInfo.sequence).longValue()); + + // Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction + this.setA2(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 8)); + this.setA3(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 16)); + this.setA4(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 24)); } @Override @@ -282,7 +249,7 @@ public class QortalATAPI extends API { byte[] hash = Crypto.digest(input); - return fromBytes(hash, 0); + return BitTwiddling.longFromBEBytes(hash, 0); } catch (DataException e) { throw new RuntimeException("AT API unable to fetch latest block from repository?", e); } @@ -296,30 +263,14 @@ public class QortalATAPI extends API { TransactionData transactionData = this.getTransactionFromA(state); - byte[] messageData = null; - - switch (transactionData.getType()) { - case MESSAGE: - messageData = ((MessageTransactionData) transactionData).getData(); - break; - - case AT: - messageData = ((ATTransactionData) transactionData).getMessage(); - break; - - default: - return; - } - - // Check data length is appropriate, i.e. not larger than B - if (messageData.length > 4 * 8) - return; + byte[] messageData = this.getMessageFromTransaction(transactionData); // Pad messageData to fit B - byte[] paddedMessageData = Bytes.ensureCapacity(messageData, 4 * 8, 0); + if (messageData.length < 4 * 8) + messageData = Bytes.ensureCapacity(messageData, 4 * 8, 0); // Endian must be correct here so that (for example) a SHA256 message can be compared to one generated locally - this.setB(state, paddedMessageData); + this.setB(state, messageData); } @Override @@ -457,12 +408,6 @@ public class QortalATAPI extends API { // Utility methods - /** Convert part of little-endian byte[] to long */ - /* package */ static long fromBytes(byte[] bytes, int start) { - return (bytes[start] & 0xffL) | (bytes[start + 1] & 0xffL) << 8 | (bytes[start + 2] & 0xffL) << 16 | (bytes[start + 3] & 0xffL) << 24 - | (bytes[start + 4] & 0xffL) << 32 | (bytes[start + 5] & 0xffL) << 40 | (bytes[start + 6] & 0xffL) << 48 | (bytes[start + 7] & 0xffL) << 56; - } - /** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */ public static byte[] partialSignature(byte[] fullSignature) { return Arrays.copyOfRange(fullSignature, 8, 32); @@ -473,7 +418,7 @@ public class QortalATAPI extends API { // Compare end of transaction's signature against A2 thru A4 byte[] sig = transactionData.getSignature(); - if (this.getA2(state) != fromBytes(sig, 8) || this.getA3(state) != fromBytes(sig, 16) || this.getA4(state) != fromBytes(sig, 24)) + if (this.getA2(state) != BitTwiddling.longFromBEBytes(sig, 8) || this.getA3(state) != BitTwiddling.longFromBEBytes(sig, 16) || this.getA4(state) != BitTwiddling.longFromBEBytes(sig, 24)) throw new IllegalStateException("Transaction signature in A no longer matches signature from repository"); } @@ -497,6 +442,20 @@ public class QortalATAPI extends API { } } + /** Returns message data from transaction. */ + /*package*/ byte[] getMessageFromTransaction(TransactionData transactionData) { + switch (transactionData.getType()) { + case MESSAGE: + return ((MessageTransactionData) transactionData).getData(); + + case AT: + return ((ATTransactionData) transactionData).getMessage(); + + default: + return null; + } + } + /** Returns AT's account */ /* package */ Account getATAccount() { return new Account(this.repository, this.atData.getATAddress()); @@ -563,4 +522,8 @@ public class QortalATAPI extends API { super.setB(state, bBytes); } + protected void zeroB(MachineState state) { + super.zeroB(state); + } + } diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index cf6b1cfd..67ab5b98 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -12,6 +12,7 @@ import org.ciyam.at.IllegalFunctionCodeException; import org.ciyam.at.MachineState; import org.qortal.crosschain.BTC; import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; /** @@ -22,8 +23,70 @@ import org.qortal.settings.Settings; */ public enum QortalFunctionCode { /** - * 0x0510
- * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3. + * Returns length of message data from transaction in A.
+ * 0x0501
+ * If transaction has no 'message', returns -1. + */ + GET_MESSAGE_LENGTH_FROM_TX_IN_A(0x0501, 0, true) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + QortalATAPI api = (QortalATAPI) state.getAPI(); + + TransactionData transactionData = api.getTransactionFromA(state); + + byte[] messageData = api.getMessageFromTransaction(transactionData); + + if (messageData == null) + functionData.returnValue = -1L; + else + functionData.returnValue = (long) messageData.length; + } + }, + /** + * Put offset 'message' from transaction in A into B
+ * 0x0502 start-offset
+ * Copies up to 32 bytes of message data, starting at start-offset into B.
+ * If transaction has no 'message', or start-offset out of bounds, then zero B
+ * Example 'message' could be 256-bit shared secret + */ + PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B(0x0502, 1, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + QortalATAPI api = (QortalATAPI) state.getAPI(); + + // In case something goes wrong, or we don't have enough message data. + api.zeroB(state); + + if (functionData.value1 < 0 || functionData.value1 > Integer.MAX_VALUE) + return; + + int startOffset = functionData.value1.intValue(); + + TransactionData transactionData = api.getTransactionFromA(state); + + byte[] messageData = api.getMessageFromTransaction(transactionData); + + if (messageData == null || startOffset > messageData.length) + return; + + /* + * Copy up to 32 bytes of message data into B, + * retain order but pad with zeros in lower bytes. + * + * So a 4-byte message "a b c d" would copy thusly: + * a b c d 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + */ + int byteCount = Math.min(32, messageData.length - startOffset); + byte[] bBytes = new byte[32]; + + System.arraycopy(messageData, startOffset, bBytes, 0, byteCount); + + api.setB(state, bBytes); + } + }, + /** + * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
+ * 0x0510 */ CONVERT_B_TO_PKH(0x0510, 0, false) { @Override @@ -38,8 +101,8 @@ public enum QortalFunctionCode { } }, /** - * 0x0511
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.
+ * 0x0511
* P2SH stored in lower 25 bytes of B. */ CONVERT_B_TO_P2SH(0x0511, 0, false) { @@ -51,8 +114,8 @@ public enum QortalFunctionCode { } }, /** - * 0x0512
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.
+ * 0x0512
* Qortal address stored in lower 25 bytes of B. */ CONVERT_B_TO_QORTAL(0x0512, 0, false) { diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index ce593bb4..fbde811b 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -792,6 +792,9 @@ public class Controller extends Thread { this.notifyGroupMembershipChange = false; ChatNotifier.getInstance().onGroupMembershipChange(); } + + // Trade-bot might want to perform some actions too + TradeBot.getInstance().onChainTipChange(); }); } diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java new file mode 100644 index 00000000..496125b4 --- /dev/null +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -0,0 +1,1049 @@ +package org.qortal.controller; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; + +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.ECKey; +import org.bitcoinj.core.Transaction; +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.asset.Asset; +import org.qortal.crosschain.BTC; +import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountBalanceData; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +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.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +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; + +public class TradeBot { + + public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } + + 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 Logger LOGGER = LogManager.getLogger(TradeBot.class); + private static final Random RANDOM = new SecureRandom(); + private static final long FEE_AMOUNT = 1000L; + + private static TradeBot instance; + + /** To help ensure only TradeBot is only active on one thread. */ + private AtomicBoolean activeFlag = new AtomicBoolean(false); + + private TradeBot() { + } + + public static synchronized TradeBot getInstance() { + if (instance == null) + instance = new TradeBot(); + + return instance; + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret-B
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Bitcoin) public key, public key hash
  • + *
  • HASH160 of secret-B
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • + *
  • 'foreign'/Bitcoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • HASH160 of secret-B - used by AT and P2SH to validate a potential secret-B
  • + *
  • QORT amount on offer by Bob
  • + *
  • BTC 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 static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = generateTradePrivateKey(); + byte[] secretB = generateSecret(); + byte[] hashOfSecretB = Crypto.hash160(secretB); + + byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) + Address bitcoinReceivingAddress; + try { + bitcoinReceivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (bitcoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] bitcoinReceivingAccountInfo = bitcoinReceivingAddress.getHash(); + + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + // Deploy AT + long timestamp = NTP.getTime(); + byte[] reference = creator.getLastReference(); + long fee = 0L; + byte[] signature = null; + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); + + String name = "QORT/BTC ACCT"; + String description = "QORT/BTC cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT BTC"; + byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + long amount = tradeBotCreateRequest.fundingQortAmount; + + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, + creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretB, hashOfSecretB, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress)); + notifyStateChange(tradeBotData); + + // Return to user for signing and broadcast as we don't have their Qortal private key + try { + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + throw new DataException("Failed to transform DEPLOY_AT transaction?", e); + } + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching BTC to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Bitcoin wallet via xprv58. + *

+ * The crossChainTradeData contains the current trade offer state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Bitcoin BIP32 hierarchical deterministic key, + * passed via xprv58. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the xprv58 can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Bitcoin main-net) + * or 'tprv' for (Bitcoin test-net). + *

+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Bitcoin amount expected by 'Bob'. + *

+ * If the Bitcoin transaction is successfully broadcast to the network then the trade-bot entry + * is saved to the repository and the cross-chain trading process commences. + *

+ * Trade-bot will wait for P2SH-A to confirm before taking next step. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param xprv58 funded wallet xprv in base58 + * @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(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH + + // We need to generate lockTime-A: halfway of refundTimeout from now + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash); + + // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin + String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); + + long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-A */ + FEE_AMOUNT /* P2SH-B */; + + Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); + if (fundingCheckTransaction == null) + return ResponseResult.INSUFFICIENT_FUNDS; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Fund P2SH-A + Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT); + if (p2shFundingTransaction == null) { + LOGGER.warn(() -> String.format("Unable to build P2SH-A funding transaction - lack of funds?")); + return ResponseResult.BTC_BALANCE_ISSUE; + } + + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { + // We couldn't fund P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?")); + return ResponseResult.BTC_NETWORK_ISSUE; + } + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + notifyStateChange(tradeBotData); + + 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(); + } + + private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + return PrivateKeyAccount.toPublicKey(privateKey); + } + + 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; + } + + public void onChainTipChange() { + // No point doing anything on old/stale data + if (!Controller.getInstance().isUpToDate()) + return; + + if (!activeFlag.compareAndSet(false, true)) + // Trade bot already active on another thread + return; + + // Get repo for trade situations + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + + for (TradeBotData tradeBotData : allTradeBotData) { + repository.discardChanges(); + + 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 (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + } finally { + activeFlag.set(false); + } + } + + /** + * Trade-bot is waiting for Bob's AT to deploy. + *

+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. + */ + private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) + return; + + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is waiting for Alice's P2SH-A to confirm. + *

+ * If P2SH-A is confirmed, then trade-bot's next step is to MESSAGE Bob's trade address with Alice's trade info. + *

+ * It is possible between broadcast and confirmation of P2SH-A funding transaction, that Bob has cancelled his trade offer. + * If this is detected then trade-bot's next step is to wait until P2SH-A can refund back to Alice. + *

+ * In normal operation, trade-bot send a zero-fee, PoW MESSAGE on Alice's behalf containing: + *

    + *
  • Alice's 'foreign'/Bitcoin public key hash - so Bob's trade-bot can derive P2SH-A address and check balance
  • + *
  • HASH160 of Alice's secret-A - also used to derive P2SH-A address
  • + *
  • lockTime of P2SH-A - also used to derive P2SH-A address, but also for other use later in the trading process
  • + *
+ * If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. + */ + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + // If AT has finished then maybe Bob cancelled his trade offer + if (atData.getIsFinished()) { + // No point sending MESSAGE - might as well wait for refund + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); + notifyStateChange(tradeBotData); + + return; + } + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < crossChainTradeData.expectedBitcoin) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-A balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + + return; + } + + // P2SH-A funding confirmed + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", + p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. + *

+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, + * in which case trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. + *

+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. + *

+ * Assuming P2SH-A has at least expected Bitcoin balance, + * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. + *

+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. + *

+ * Trade-bot's next step is to wait for P2SH-B, which will allow Bob to reveal his secret-B, + * needed by Alice to progress her side of the trade. + */ + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { + // Fetch AT so we can determine trade start timestamp + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + + return; + } + + String address = tradeBotData.getTradeNativeAddress(); + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); + + final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); + + // Skip past previously processed messages + if (originalLastTransactionSignature != null) + for (int i = 0; i < messageTransactionsData.size(); ++i) + if (Arrays.equals(messageTransactionsData.get(i).getSignature(), originalLastTransactionSignature)) { + messageTransactionsData.subList(0, i + 1).clear(); + break; + } + + while (!messageTransactionsData.isEmpty()) { + MessageTransactionData messageTransactionData = messageTransactionsData.remove(0); + tradeBotData.setLastTransactionSignature(messageTransactionData.getSignature()); + + if (messageTransactionData.isText()) + continue; + + // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + // Determine P2SH-A address and confirm funded + byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < tradeBotData.getBitcoinAmount()) + continue; + + // Good to go - send MESSAGE to AT + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume + byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); + + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", outgoingMessageTransaction.getRecipient(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress)); + notifyStateChange(tradeBotData); + + return; + } + + // Don't resave if we don't need to + if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) { + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + notifyStateChange(tradeBotData); + } + } + + /** + * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. + *

+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow + * this process has taken so long that we've reached P2SH-A's locktime, or that someone else + * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. + *

+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. + *

+ * If all is well, trade-bot then uses Bitcoin wallet to (token) fund P2SH-B. + *

+ * If P2SH-B funding transaction is successfully broadcast to the Bitcoin network, trade-bot's next + * step is to watch for Bob revealing secret-B by redeeming P2SH-B. + */ + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // 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) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + if (atData.getIsFinished()) + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); + else + LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress)); + + notifyStateChange(tradeBotData); + + return; + } + + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != BTCACCT.Mode.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 = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", + tradeBotData.getAtAddress(), + crossChainTradeData.qortalPartnerAddress, + tradeBotData.getTradeNativeAddress(), + p2shAddress)); + + // There's no P2SH-B at this point, so jump straight to refunding P2SH-A + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + notifyStateChange(tradeBotData); + + return; + } + + // Alice needs to fund P2SH-B here + + // Find our MESSAGE to AT from previous state + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null); + if (messageTransactionsData == null) { + LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT %s from repository", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + // Find our message + Long recipientMessageTimestamp = null; + for (MessageTransactionData messageTransactionData : messageTransactionsData) + if (Arrays.equals(messageTransactionData.getSenderPublicKey(), tradeBotData.getTradeNativePublicKey())) { + recipientMessageTimestamp = messageTransactionData.getTimestamp(); + break; + } + + if (recipientMessageTimestamp == null) { + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + int lockTimeA = tradeBotData.getLockTimeA(); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); + + // Our calculated lockTime-B should match AT's calculated lockTime-B + if (lockTimeB != crossChainTradeData.lockTimeB) { + LOGGER.debug(() -> String.format("Trade AT lockTime-B '%d' doesn't match our lockTime-B '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + // We'll eventually refund + return; + } + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT); + if (p2shFundingTransaction == null) { + LOGGER.warn(() -> String.format("Unable to build P2SH-B funding transaction - lack of funds?")); + return; + } + + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { + // We couldn't fund P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?")); + return; + } + + // P2SH-B funded, now we wait for Bob to redeem it + tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", + tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); + + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is waiting for P2SH-B to funded. + *

+ * It's possible than Bob's AT has reached it's trading timeout and automatically refunded QORT back to Bob. + * In which case, trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming P2SH-B is funded, trade-bot 'redeems' this P2SH using secret-B, thus revealing it to Alice. + *

+ * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. + */ + private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // If we've passed AT refund timestamp then AT will have finished after auto-refunding + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + + return; + } + + // It's possible AT hasn't processed our previous MESSAGE yet and so lockTimeB won't be set + if (crossChainTradeData.lockTimeB == null) + // AT yet to process MESSAGE + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < FEE_AMOUNT) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-B balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); + + return; + } + + // Redeem P2SH-B using secret-B + Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivingAccountInfo); + + if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { + // We couldn't redeem P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B redeeming transaction?")); + return; + } + + // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is waiting for Bob to redeem P2SH-B thus revealing secret-B to Alice. + *

+ * It's possible that this process has taken so long that we've reached P2SH-B's locktime. + * In which case, trade-bot switches to begin the refund process. + *

+ * If trade-bot can extract a valid secret-B from the spend of P2SH-B, then it creates a + * zero-fee, PoW MESSAGE to send to Bob's AT, including both secret-B and also Alice's secret-A. + *

+ * Both secrets are needed to release the QORT funds from Bob's AT to Alice's 'native'/Qortal + * trade address. + *

+ * In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A. + *

+ * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. + */ + private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Refund P2SH-B if we've passed lockTime-B + if (NTP.getTime() >= crossChainTradeData.lockTimeB * 1000L) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress)); + notifyStateChange(tradeBotData); + + return; + } + + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); + if (p2shTransactions == null) { + LOGGER.debug(() -> String.format("Unable to fetch transactions relating to %s", p2shAddress)); + return; + } + + byte[] secretB = BTCP2SH.findP2shSecret(p2shAddress, p2shTransactions); + if (secretB == null) + // Secret not revealed at this time + return; + + // Send 'redeem' MESSAGE to AT using both secrets + byte[] secretA = tradeBotData.getSecret(); + String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH + byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // Reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_DONE); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + String receivingAddress = tradeBotData.getTradeNativeAddress(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", + p2shAddress, tradeBotData.getAtAddress(), receivingAddress)); + + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the BTC funds from P2SH-A. + *

+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, + * trade-bot is done with this specific trade and finalizes in refunded state. + *

+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the BTC funds from P2SH-A + * to Bob's 'foreign'/Bitcoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output). + *

+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + */ + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // 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 (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); + + return; + } + + byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivingAccountInfo); + + if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { + // We couldn't redeem P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A redeeming transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.BOB_DONE); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo); + + LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); + notifyStateChange(tradeBotData); + } + + /** + * Trade-bot is attempting to refund P2SH-B. + *

+ * We could potentially skip this step as P2SH-B is only funded with a token amount to cover the mining fee should Bob redeem P2SH-B. + *

+ * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. + */ + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-B until lockTime-B has passed + if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.ZERO; + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB); + if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { + // We couldn't refund P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); + notifyStateChange(tradeBotData); + } + + /** Trade-bot is attempting to refund P2SH-A. */ + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L) + return; + + // We can't refund P2SH-A until we've passed median block time + Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); + if (medianBlockTime == null || NTP.getTime() <= medianBlockTime * 1000L) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + if (fundingOutputs == null) { + LOGGER.debug(() -> String.format("Couldn't fetch unspent outputs for %s", p2shAddress)); + return; + } + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { + // We couldn't refund P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A refund transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress)); + notifyStateChange(tradeBotData); + } + + private static void notifyStateChange(TradeBotData tradeBotData) { + StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); + EventBus.INSTANCE.notify(stateChangeEvent); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index ec53eb08..5e5a3639 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -1,28 +1,45 @@ package org.qortal.crosschain; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.InsufficientMoneyException; +import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.UTXO; +import org.bitcoinj.core.UTXOProvider; +import org.bitcoinj.core.UTXOProviderException; +import org.bitcoinj.crypto.DeterministicHierarchy; +import org.bitcoinj.crypto.DeterministicKey; import org.bitcoinj.params.MainNetParams; import org.bitcoinj.params.RegTestParams; import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.Script.ScriptType; import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.utils.MonetaryFormat; +import org.bitcoinj.wallet.DeterministicKeyChain; +import org.bitcoinj.wallet.SendRequest; +import org.bitcoinj.wallet.Wallet; +import org.qortal.crosschain.ElectrumX.UnspentOutput; +import org.qortal.crypto.Crypto; import org.qortal.settings.Settings; import org.qortal.utils.BitTwiddling; -import org.qortal.utils.Pair; + +import com.google.common.hash.HashCode; public class BTC { - public static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode(); public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL; public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1; public static final int HASH160_LENGTH = 20; @@ -30,6 +47,7 @@ public class BTC { protected static final Logger LOGGER = LogManager.getLogger(BTC.class); private static final int TIMESTAMP_OFFSET = 4 + 32 + 32; + private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode(); public enum BitcoinNet { MAIN { @@ -58,6 +76,9 @@ public class BTC { private final NetworkParameters params; private final ElectrumX electrumX; + // Let ECKey.equals() do the hard work + private final Set spentKeys = new HashSet<>(); + // Constructors and instance private BTC() { @@ -88,6 +109,34 @@ public class BTC { // Actual useful methods for use by other classes + public static String format(Coin amount) { + return BTC.FORMAT.format(amount).toString(); + } + + public static String format(long amount) { + return format(Coin.valueOf(amount)); + } + + public boolean isValidXprv(String xprv58) { + try { + DeterministicKey.deserializeB58(null, xprv58, this.params); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + /** Returns P2PKH Bitcoin address using passed public key hash. */ + public String pkhToAddress(byte[] publicKeyHash) { + return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString(); + } + + public String deriveP2shAddress(byte[] redeemScriptBytes) { + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + return p2shAddress.toString(); + } + /** Returns median timestamp from latest 11 blocks, in seconds. */ public Integer getMedianBlockTime() { Integer height = this.electrumX.getCurrentHeight(); @@ -99,34 +148,31 @@ public class BTC { if (blockHeaders == null || blockHeaders.size() < 11) return null; - List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); + List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); - // Descending, but order shouldn't matter as we're picking median... + // Descending order blockTimestamps.sort((a, b) -> Integer.compare(b, a)); + // Pick median return blockTimestamps.get(5); } - public Coin getBalance(String base58Address) { - Long balance = this.electrumX.getBalance(addressToScript(base58Address)); - if (balance == null) - return null; - - return Coin.valueOf(balance); + public Long getBalance(String base58Address) { + return this.electrumX.getBalance(addressToScript(base58Address)); } public List getUnspentOutputs(String base58Address) { - List> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); + List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); if (unspentOutputs == null) return null; List unspentTransactionOutputs = new ArrayList<>(); - for (Pair unspentOutput : unspentOutputs) { - List transactionOutputs = getOutputs(unspentOutput.getA()); + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = getOutputs(unspentOutput.hash); if (transactionOutputs == null) return null; - unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB())); + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); } return unspentTransactionOutputs; @@ -141,6 +187,7 @@ public class BTC { return transaction.getOutputs(); } + /** Returns list of raw transactions spending passed address. */ public List getAddressTransactions(String base58Address) { return this.electrumX.getAddressTransactions(addressToScript(base58Address)); } @@ -149,6 +196,181 @@ public class BTC { return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); } + /** + * Returns bitcoinj transaction sending amount to recipient. + * + * @param xprv58 BIP32 extended Bitcoin private key + * @param recipient P2PKH address + * @param amount unscaled amount + * @return transaction, or null if insufficient funds + */ + public Transaction buildSpend(String xprv58, String recipient, long amount) { + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT)); + + Address destination = Address.fromString(this.params, recipient); + SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); + + if (this.params == TestNet3Params.get()) + // Much smaller fee for TestNet3 + sendRequest.feePerKb = Coin.valueOf(2000L); + + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } + + /** + * Returns unspent Bitcoin balance given 'm' BIP32 key. + * + * @param xprv58 BIP32 extended Bitcoin private key + * @return unspent BTC balance, or null if unable to determine balance + */ + public Long getWalletBalance(String xprv58) { + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT)); + + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; + } + + // UTXOProvider support + + static class WalletAwareUTXOProvider implements UTXOProvider { + private static final int LOOKAHEAD_INCREMENT = 3; + + private final BTC btc; + private final Wallet wallet; + + enum KeySearchMode { + REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT; + } + private final KeySearchMode keySearchMode; + private final DeterministicKeyChain keyChain; + + public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) { + this.btc = btc; + this.wallet = wallet; + this.keySearchMode = keySearchMode; + this.keyChain = this.wallet.getActiveKeyChain(); + + // Set up wallet's key chain + this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT); + this.keyChain.maybeLookAhead(); + } + + public List getOpenTransactionOutputs(List keys) throws UTXOProviderException { + List allUnspentOutputs = new ArrayList<>(); + final boolean coinbase = false; + + int ki = 0; + do { + boolean areAllKeysUnspent = true; + boolean areAllKeysSpent = true; + + for (; ki < keys.size(); ++ki) { + ECKey key = keys.get(ki); + + Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List unspentOutputs = btc.electrumX.getUnspentOutputs(script); + if (unspentOutputs == null) + throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); + + /* + * If there are no unspent outputs then either: + * a) all the outputs have been spent + * b) address has never been used + * + * For case (a) we want to remember not to check this address (key) again. + */ + + if (unspentOutputs.isEmpty()) { + // If this is a known key that has been spent before, then we can skip asking for transaction history + if (btc.spentKeys.contains(key)) { + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + areAllKeysUnspent = false; + continue; + } + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = btc.electrumX.getAddressTransactions(script); + if (historicTransactionHashes == null) + throw new UTXOProviderException( + String.format("Unable to fetch transaction history for %s", address)); + + if (!historicTransactionHashes.isEmpty()) { + // Fully spent key - case (a) + btc.spentKeys.add(key); + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + areAllKeysUnspent = false; + } else { + // Key never been used - case (b) + areAllKeysSpent = false; + } + + continue; + } + + // If we reach here, then there's definitely at least one unspent key + areAllKeysSpent = false; + + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = btc.getOutputs(unspentOutput.hash); + if (transactionOutputs == null) + throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s", + HashCode.fromBytes(unspentOutput.hash))); + + TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index); + + UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index, + Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase, + transactionOutput.getScriptPubKey()); + + allUnspentOutputs.add(utxo); + } + } + + if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent) + || (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) { + // Generate some more keys + this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT); + this.keyChain.maybeLookAhead(); + + // This returns all keys, including those already in 'keys' + List allLeafKeys = this.keyChain.getLeafKeys(); + // Add only new keys onto our list of keys to search + List newKeys = allLeafKeys.subList(ki, allLeafKeys.size()); + keys.addAll(newKeys); + // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again + } + + // If we have processed all keys, then we're done + } while (ki < keys.size()); + + return allUnspentOutputs; + } + + public int getChainHeadHeight() throws UTXOProviderException { + Integer height = btc.electrumX.getCurrentHeight(); + if (height == null) + throw new UTXOProviderException("Unable to determine Bitcoin chain height"); + + return height.intValue(); + } + + public NetworkParameters getParams() { + return btc.params; + } + } + // Utility methods for us private byte[] addressToScript(String base58Address) { diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index a0246d04..1adacfb8 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -1,26 +1,14 @@ package org.qortal.crosschain; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; -import java.util.function.Function; +import java.util.Map; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.LegacyAddress; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.core.Transaction; -import org.bitcoinj.core.Transaction.SigHash; -import org.bitcoinj.core.TransactionInput; -import org.bitcoinj.core.TransactionOutput; -import org.bitcoinj.crypto.TransactionSignature; -import org.bitcoinj.script.Script; -import org.bitcoinj.script.ScriptBuilder; -import org.bitcoinj.script.ScriptChunk; -import org.bitcoinj.script.ScriptOpCodes; import org.ciyam.at.API; import org.ciyam.at.CompilationException; import org.ciyam.at.FunctionCode; @@ -29,14 +17,12 @@ import org.ciyam.at.OpCode; import org.ciyam.at.Timestamp; import org.qortal.account.Account; import org.qortal.asset.Asset; -import org.qortal.at.QortalAtLoggerFactory; -import org.qortal.block.BlockChain; -import org.qortal.block.BlockChain.CiyamAtSettings; +import org.qortal.at.QortalFunctionCode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; -import org.qortal.data.block.BlockData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Base58; @@ -45,246 +31,197 @@ import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -/* - * Bob generates Bitcoin private key - * private key required to sign P2SH redeem tx - * private key can be used to create 'secret' (e.g. double-SHA256) - * encrypted private key could be stored in Qortal AT for access by Bob from any node - * Bob creates Qortal AT - * Alice finds Qortal AT and wants to trade - * Alice generates Bitcoin private key - * Alice will need to send Bob her Qortal address and Bitcoin refund address - * Bob sends Alice's Qortal address to Qortal AT - * Qortal AT sends initial QORT payment to Alice (so she has QORT to send message to AT and claim funds) - * Alice receives funds and checks Qortal AT to confirm it's locked to her - * Alice creates/funds Bitcoin P2SH - * Alice requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime - * Bob checks P2SH is funded - * Bob requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime - * Bob uses secret to redeem P2SH - * Qortal core/UI will need to create, and sign, this transaction - * Alice scans P2SH redeem tx and uses secret to redeem Qortal AT +/** + * Cross-chain trade AT + * + *

+ *

    + *
  • Bob generates Bitcoin & Qortal 'trade' keys, and secret-b + *
      + *
    • private key required to sign P2SH redeem tx
    • + *
    • private key could be used to create 'secret' (e.g. double-SHA256)
    • + *
    • encrypted private key could be stored in Qortal AT for access by Bob from any node
    • + *
    + *
  • + *
  • Bob deploys Qortal AT + *
      + *
    + *
  • + *
  • Alice finds Qortal AT and wants to trade + *
      + *
    • Alice generates Bitcoin & Qortal 'trade' keys
    • + *
    • Alice funds Bitcoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Bitcoin PKH
      • + *
      + *
    • + *
    + *
  • + *
  • Bob receives "offer" MESSAGE + *
      + *
    • Checks Alice's P2SH-A
    • + *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: + *
        + *
      • Alice's trade Qortal address
      • + *
      • Alice's trade Bitcoin PKH
      • + *
      • hash-of-secret-A
      • + *
      + *
    • + *
    + *
  • + *
  • Alice checks Qortal AT to confirm it's locked to her + *
      + *
    • Alice creates/funds Bitcoin P2SH-B
    • + *
    + *
  • + *
  • Bob checks P2SH-B is funded + *
      + *
    • Bob redeems P2SH-B using his Bitcoin trade key and secret-B
    • + *
    + *
  • + *
  • Alice scans P2SH-B redeem transaction to extract secret-B + *
      + *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
        + *
      • secret-A
      • + *
      • secret-B
      • + *
      • Qortal receiving address of her chosing
      • + *
      + *
    • + *
    • AT's QORT funds are sent to Qortal receiving address
    • + *
    + *
  • + *
  • Bob checks AT, extracts secret-A + *
      + *
    • Bob redeems P2SH-A using his Bitcoin trade key and secret-A
    • + *
    • P2SH-A BTC funds end up at Bitcoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
*/ - public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes - /* - * OP_TUCK (to copy public key to before signature) - * OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails) - * OP_HASH160 (convert public key to PKH) - * OP_DUP (duplicate PKH) - * OP_EQUAL (does PKH match refund PKH?) - * OP_IF - * OP_DROP (no need for duplicate PKH) - * - * OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is so script passes) - * OP_ELSE - * OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails) - * OP_HASH160 (hash secret) - * OP_EQUAL (do hashes of secrets match? if true, script passes else script fails) - * OP_ENDIF - */ + /** 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; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); - private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes) - private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes) - private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes) - private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes) - private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF - - /** - * Returns Bitcoin redeemScript used for cross-chain trading. - *

- * See comments in {@link BTCACCT} for more details. - * - * @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes - * @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund - * @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key - * @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds - * @return - */ - public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) { - return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)), - redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5); + public static class OfferMessageData { + public byte[] partnerBitcoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerBitcoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; + public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ + + 24 /*partner's Bitcoin PKH (padded from 20 to 24)*/ + + 8 /*lockTimeB*/ + + 24 /*hash of secret-A (padded from 20 to 24)*/ + + 8 /*lockTimeA*/; + 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*/; - /** - * Builds a custom transaction to spend P2SH. - * - * @param amount output amount, should be total of input amounts, less miner fees - * @param spendKey key for signing transaction, and also where funds are 'sent' (output) - * @param fundingOutput output from transaction that funded P2SH address - * @param redeemScriptBytes the redeemScript itself, in byte[] form - * @param lockTime (optional) transaction nLockTime, used in refund scenario - * @param scriptSigBuilder function for building scriptSig using transaction input signature - * @return Signed Bitcoin transaction for spending P2SH - */ - public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) { - NetworkParameters params = BTC.getInstance().getNetworkParameters(); + public enum Mode { + OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4); - Transaction transaction = new Transaction(params); - transaction.setVersion(2); + public final int value; + private static final Map map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode)); - // Output is back to P2SH funder - transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash())); - - for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { - TransactionOutput fundingOutput = fundingOutputs.get(inputIndex); - - // Input (without scriptSig prior to signing) - TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor()); - if (lockTime != null) - input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF - else - input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF - transaction.addInput(input); + Mode(int value) { + this.value = value; } - // Set locktime after inputs added but before input signatures are generated - if (lockTime != null) - transaction.setLockTime(lockTime); - - for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { - // Generate transaction signature for input - final boolean anyoneCanPay = false; - TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay); - - // Calculate transaction signature - byte[] txSigBytes = txSig.encodeToBitcoin(); - - // Build scriptSig using lambda and tx signature - Script scriptSig = scriptSigBuilder.apply(txSigBytes); - - // Set input scriptSig - transaction.getInput(inputIndex).setScriptSig(scriptSig); + public static Mode valueOf(int value) { + return map.get(value); } - - return transaction; } - /** - * Returns signed Bitcoin transaction claiming refund from P2SH address. - * - * @param refundAmount refund amount, should be total of input amounts, less miner fees - * @param refundKey key for signing transaction, and also where refund is 'sent' (output) - * @param fundingOutput output from transaction that funded P2SH address - * @param redeemScriptBytes the redeemScript itself, in byte[] form - * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript - * @return Signed Bitcoin transaction for refunding P2SH - */ - public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime) { - Function refundSigScriptBuilder = (txSigBytes) -> { - // Build scriptSig with... - ScriptBuilder scriptBuilder = new ScriptBuilder(); - - // transaction signature - scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); - - // redeem public key - byte[] refundPubKey = refundKey.getPubKey(); - scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey)); - - // redeem script - scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); - - return scriptBuilder.build(); - }; - - return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder); - } - - /** - * Returns signed Bitcoin transaction redeeming funds from P2SH address. - * - * @param redeemAmount redeem amount, should be total of input amounts, less miner fees - * @param redeemKey key for signing transaction, and also where funds are 'sent' (output) - * @param fundingOutput output from transaction that funded P2SH address - * @param redeemScriptBytes the redeemScript itself, in byte[] form - * @param secret actual 32-byte secret used when building redeemScript - * @return Signed Bitcoin transaction for redeeming P2SH - */ - public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret) { - Function redeemSigScriptBuilder = (txSigBytes) -> { - // Build scriptSig with... - ScriptBuilder scriptBuilder = new ScriptBuilder(); - - // secret - scriptBuilder.addChunk(new ScriptChunk(secret.length, secret)); - - // transaction signature - scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); - - // redeem public key - byte[] redeemPubKey = redeemKey.getPubKey(); - scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey)); - - // redeem script - scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); - - return scriptBuilder.build(); - }; - - return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder); + private BTCACCT() { } /** * Returns Qortal AT creation bytes for cross-chain trading AT. *

- * tradeTimeout (minutes) is the time window for the recipient to send the + * tradeTimeout (minutes) is the time window for the trade partner to send the * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. * - * @param qortalCreator Qortal address for AT creator, also used for refunds - * @param secretHash 20-byte HASH160 of 32-byte secret - * @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator - * @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode' - * @param redeemPayout how much QORT to pay trade partner if they send correct 32-byte secret to AT + * @param creatorTradeAddress AT creator's trade Qortal address, also used for refunds + * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's trade Bitcoin public key + * @param hashOfSecretB 20-byte HASH160 of 32-byte secret-B + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT * @param bitcoinAmount how much BTC the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade * @return */ - public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) { + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { // Labels for data segment addresses int addrCounter = 0; // Constants (with corresponding dataByteBuffer.put*() calls below) - final int addrQortalCreator1 = addrCounter++; - final int addrQortalCreator2 = addrCounter++; - final int addrQortalCreator3 = addrCounter++; - final int addrQortalCreator4 = addrCounter++; + final int addrCreatorTradeAddress1 = addrCounter++; + final int addrCreatorTradeAddress2 = addrCounter++; + final int addrCreatorTradeAddress3 = addrCounter++; + final int addrCreatorTradeAddress4 = addrCounter++; - final int addrSecretHash = addrCounter; + final int addrBitcoinPublicKeyHash = addrCounter; addrCounter += 4; - final int addrTradeTimeout = addrCounter++; - final int addrInitialPayoutAmount = addrCounter++; - final int addrRedeemPayoutAmount = addrCounter++; + final int addrHashOfSecretB = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; final int addrBitcoinAmount = addrCounter++; + final int addrTradeTimeout = addrCounter++; - final int addrMessageTxType = addrCounter++; + final int addrMessageTxnType = addrCounter++; + final int addrExpectedTradeMessageLength = addrCounter++; + final int addrExpectedRedeemMessageLength = addrCounter++; - final int addrSecretHashPointer = addrCounter++; - final int addrQortalRecipientPointer = addrCounter++; + final int addrCreatorAddressPointer = addrCounter++; + final int addrHashOfSecretBPointer = addrCounter++; + final int addrQortalPartnerAddressPointer = addrCounter++; final int addrMessageSenderPointer = addrCounter++; + final int addrTradeMessagePartnerBitcoinPKHOffset = addrCounter++; + final int addrPartnerBitcoinPKHPointer = addrCounter++; + final int addrTradeMessageHashOfSecretAOffset = addrCounter++; + final int addrHashOfSecretAPointer = addrCounter++; + + final int addrRedeemMessageSecretBOffset = addrCounter++; + final int addrRedeemMessageReceivingAddressOffset = addrCounter++; + final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; + final int addrPartnerReceivingAddressPointer = addrCounter++; + final int addrEndOfConstants = addrCounter; // Variables - final int addrQortalRecipient1 = addrCounter++; - final int addrQortalRecipient2 = addrCounter++; - final int addrQortalRecipient3 = addrCounter++; - final int addrQortalRecipient4 = addrCounter++; + final int addrCreatorAddress1 = addrCounter++; + final int addrCreatorAddress2 = addrCounter++; + final int addrCreatorAddress3 = addrCounter++; + final int addrCreatorAddress4 = addrCounter++; - final int addrTradeRefundTimestamp = addrCounter++; - final int addrLastTxTimestamp = addrCounter++; + final int addrQortalPartnerAddress1 = addrCounter++; + final int addrQortalPartnerAddress2 = addrCounter++; + final int addrQortalPartnerAddress3 = addrCounter++; + final int addrQortalPartnerAddress4 = addrCounter++; + + final int addrLockTimeA = addrCounter++; + final int addrLockTimeB = addrCounter++; + final int addrRefundTimeout = addrCounter++; + final int addrRefundTimestamp = addrCounter++; + final int addrLastTxnTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; - final int addrTxType = addrCounter++; + final int addrTxnType = addrCounter++; final int addrResult = addrCounter++; final int addrMessageSender1 = addrCounter++; @@ -292,72 +229,131 @@ public class BTCACCT { final int addrMessageSender3 = addrCounter++; final int addrMessageSender4 = addrCounter++; + final int addrMessageLength = addrCounter++; + final int addrMessageData = addrCounter; addrCounter += 4; + final int addrHashOfSecretA = addrCounter; + addrCounter += 4; + + final int addrPartnerBitcoinPKH = addrCounter; + addrCounter += 4; + + final int addrPartnerReceivingAddress = addrCounter; + addrCounter += 4; + + final int addrMode = addrCounter++; + assert addrMode == MODE_VALUE_OFFSET : "MODE_VALUE_OFFSET does not match addrMode"; + // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - // AT creator's Qortal address, decoded from Base58 - assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect"; - byte[] qortalCreatorBytes = Base58.decode(qortalCreator); - dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0)); + // AT creator's trade Qortal address, decoded from Base58 + assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; + byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); + dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); - // Hash of secret - assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect"; - dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0)); + // Bitcoin public key hash + assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0)); - // Trade timeout in minutes - assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; - dataByteBuffer.putLong(tradeTimeout); + // Hash of secret-B + assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0)); - // Initial payout amount - assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect"; - dataByteBuffer.putLong(initialPayout); - - // Redeem payout amount - assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect"; - dataByteBuffer.putLong(redeemPayout); + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); // Expected Bitcoin amount assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect"; dataByteBuffer.putLong(bitcoinAmount); + // Suggested trade timeout (minutes) + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); + // We're only interested in MESSAGE transactions - assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect"; + assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); - // Index into data segment of hash, used by GET_B_IND - assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect"; - dataByteBuffer.putLong(addrSecretHash); + // Expected length of 'trade' MESSAGE data from AT creator + assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; + dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); - // Index into data segment of recipient address, used by SET_B_IND - assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect"; - dataByteBuffer.putLong(addrQortalRecipient1); + // Expected length of 'redeem' MESSAGE data from trade partner + assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; + dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); + + // Index into data segment of AT creator's address, used by GET_B_IND + assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; + dataByteBuffer.putLong(addrCreatorAddress1); + + // Index into data segment of hash of secret B, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretB); + + // Index into data segment of partner's Qortal address, used by SET_B_IND + assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; + dataByteBuffer.putLong(addrQortalPartnerAddress1); // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; dataByteBuffer.putLong(addrMessageSender1); + // Offset into 'trade' MESSAGE data payload for extracting partner's Bitcoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerBitcoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Bitcoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerBitcoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerBitcoinPKH); + + // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Index into data segment to hash of secret A, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretA); + + // Offset into 'redeem' MESSAGE data payload for extracting secret-B + assert dataByteBuffer.position() == addrRedeemMessageSecretBOffset * MachineState.VALUE_SIZE : "addrRedeemMessageSecretBOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address + assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; + dataByteBuffer.putLong(64L); + // Source location and length for hashing any passed secret assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; dataByteBuffer.putLong(addrMessageData); assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; dataByteBuffer.putLong(32L); + // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; + dataByteBuffer.putLong(addrPartnerReceivingAddress); + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; // Code labels Integer labelRefund = null; - Integer labelOfferTxLoop = null; - Integer labelCheckOfferTx = null; + Integer labelTradeTxnLoop = null; + Integer labelCheckTradeTxn = null; + Integer labelCheckCancelTxn = null; + Integer labelNotTradeNorCancelTxn = null; + Integer labelCheckNonRefundTradeTxn = null; + Integer labelTradeTxnExtract = null; + Integer labelRedeemTxnLoop = null; + Integer labelCheckRedeemTxn = null; + Integer labelCheckRedeemTxnSender = null; + Integer labelCheckSecretB = null; + Integer labelPayout = null; - Integer labelTradeMode = null; - Integer labelTradeTxLoop = null; - Integer labelCheckTradeTx = null; - - ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); // Two-pass version for (int pass = 0; pass < 2; ++pass) { @@ -367,146 +363,223 @@ public class BTCACCT { /* Initialization */ // Use AT creation 'timestamp' as starting point for finding transactions sent to AT - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for message from AT owner containing trade partner details, or AT owner's address to cancel offer */ + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ /* Transaction processing loop */ - labelOfferTxLoop = codeByteBuffer.position(); + labelTradeTxnLoop = codeByteBuffer.position(); - // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); - // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckOfferTx))); + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn))); // Stop and wait for next block codeByteBuffer.put(OpCode.STP_IMD.compile()); /* Check transaction */ - labelCheckOfferTx = codeByteBuffer.position(); + labelCheckTradeTxn = codeByteBuffer.position(); // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - /* Check transaction's sender */ + /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); - // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop))); + // Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + // Message sender's address matches AT creator's trade address so go process 'trade' message + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); - /* Extract trade partner info from message */ + /* Checking message sender for possible cancel message */ + labelCheckCancelTxn = codeByteBuffer.position(); + + // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + // Partner address is AT creator's address, so cancel offer and finish. + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + /* Not trade nor cancel message */ + labelNotTradeNorCancelTxn = codeByteBuffer.position(); + + // Loop to find another transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Possible switch-to-trade-mode message */ + labelCheckNonRefundTradeTxn = codeByteBuffer.position(); + + // Check 'trade' message we received has expected number of message bytes + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to info extraction code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); + // Message length didn't match - go back to finding another 'trade' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Extracting info from 'trade' MESSAGE transaction */ + labelTradeTxnExtract = codeByteBuffer.position(); // Extract message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer)); - // Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrQortalCreator1, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrQortalCreator2, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrQortalCreator3, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrQortalCreator4, calcOffset(codeByteBuffer, labelTradeMode))); - // Recipient address is AT creator's address, so cancel offer and finish. - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); + // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); - /* Switch to 'trade mode' */ - labelTradeMode = codeByteBuffer.position(); + // Extract trade partner's Bitcoin public key hash (PKH) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset)); + // Extract partner's Bitcoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer)); + // Also extract lockTimeB + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeB)); - // Send initial payment to recipient so they have enough funds to message AT if all goes well - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrInitialPayoutAmount)); + // Grab next 32 bytes + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); - // Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout)); + // Extract hash-of-secret-a (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + // Extract lockTimeA (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); + + // Calculate trade refund timeout: (lockTimeA - lockTimeB) / 2 / 60 + codeByteBuffer.put(OpCode.SET_DAT.compile(addrRefundTimeout, addrLockTimeA)); // refundTimeout = lockTimeA + codeByteBuffer.put(OpCode.SUB_DAT.compile(addrRefundTimeout, addrLockTimeB)); // refundTimeout -= lockTimeB + codeByteBuffer.put(OpCode.DIV_VAL.compile(addrRefundTimeout, 2L * 60L)); // refundTimeout /= 2 * 60 + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); + + /* We are in 'trade mode' */ + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.TRADING.value)); // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */ + /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ // Fetch current block 'timestamp' codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); // If we're not past refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); // We're past refund 'timestamp' so go refund everything back to AT creator codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); /* Transaction processing loop */ - labelTradeTxLoop = codeByteBuffer.position(); + labelRedeemTxnLoop = codeByteBuffer.position(); // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTx))); + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn))); // Stop and wait for next block codeByteBuffer.put(OpCode.STP_IMD.compile()); /* Check transaction */ - labelCheckTradeTx = codeByteBuffer.position(); + labelCheckRedeemTxn = codeByteBuffer.position(); // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check message payload length */ + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to sender checking code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); + // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); /* Check transaction's sender */ + labelCheckRedeemTxnSender = codeByteBuffer.position(); // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - /* Check 'secret' in transaction's message */ + /* Check 'secret-A' in transaction's message */ - // Extract message from transaction into B register + // Extract secret-A from first 32 bytes of message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); - // Load B register with expected hash result (as pointed to by addrSecretHashPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer)); + // Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). // Save the equality result (1 if they match, 0 otherwise) into addrResult. codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); // If hashes don't match, addrResult will be zero so go find another transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - /* Success! Pay arranged amount to intended recipient */ + /* Check 'secret-B' in transaction's message */ + + labelCheckSecretB = codeByteBuffer.position(); + + // Extract secret-B from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageSecretBOffset)); + // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); + // Load B register with expected hash result (as pointed to by addrHashOfSecretBPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretBPointer)); + // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). + // Save the equality result (1 if they match, 0 otherwise) into addrResult. + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); + // If hashes don't match, addrResult will be zero so go find another transaction + codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Success! Pay arranged amount to receiving address */ + labelPayout = codeByteBuffer.position(); + + // Extract Qortal receiving address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); + // Pay AT's balance to receiving address + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); + // Set redeemed mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); - // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); - // Pay AT's balance to recipient - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount)); // Fall-through to refunding any remaining balance back to AT creator /* Refund balance back to AT creator */ labelRefund = codeByteBuffer.position(); - // Load B register with AT creator's address. - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); - // Pay AT's balance back to AT's creator. - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B)); - // We're finished forever + // Set refunded mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REFUNDED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) codeByteBuffer.put(OpCode.FIN_IMD.compile()); } catch (CompilationException e) { throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e); @@ -537,130 +610,306 @@ public class BTCACCT { * @throws DataException */ public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { - String atAddress = atData.getATAddress(); + ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); + } - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - byte[] stateData = atStateData.getStateData(); + /** + * Returns CrossChainTradeData with useful info extracted from AT. + * + * @param repository + * @param atAddress + * @throws DataException + */ + public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { + byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress()); + return populateTradeData(repository, creatorPublicKey, atStateData); + } - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + /** + * Returns CrossChainTradeData with useful info extracted from AT. + * + * @param repository + * @param atAddress + * @throws DataException + */ + public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + byte[] addressBytes = new byte[25]; // for general use + String atAddress = atStateData.getATAddress(); CrossChainTradeData tradeData = new CrossChainTradeData(); + tradeData.qortalAtAddress = atAddress; - tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); - tradeData.creationTimestamp = atData.getCreation(); + tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); + tradeData.creationTimestamp = atStateData.getCreation(); Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); - ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); - byte[] addressBytes = new byte[32]; + byte[] stateData = atStateData.getStateData(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); + dataByteBuffer.position(MachineState.HEADER_LENGTH); - // Skip AT creator address - dataByteBuffer.position(dataByteBuffer.position() + 32); + /* Constants */ - // Hash of secret - tradeData.secretHash = new byte[20]; - dataByteBuffer.get(tradeData.secretHash); - dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes + // Skip creator's trade address + dataByteBuffer.get(addressBytes); + tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); - // Trade timeout - tradeData.tradeRefundTimeout = dataByteBuffer.getLong(); + // Creator's Bitcoin/foreign public key hash + tradeData.creatorBitcoinPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes - // Initial payout - tradeData.initialPayout = dataByteBuffer.getLong(); + // Hash of secret B + tradeData.hashOfSecretB = new byte[20]; + dataByteBuffer.get(tradeData.hashOfSecretB); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.hashOfSecretB.length); // skip to 32 bytes // Redeem payout - tradeData.redeemPayout = dataByteBuffer.getLong(); + tradeData.qortAmount = dataByteBuffer.getLong(); // Expected BTC amount tradeData.expectedBitcoin = dataByteBuffer.getLong(); + // Trade timeout + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + // Skip MESSAGE transaction type dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip pointer to secretHash + // Skip expected 'trade' message length dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip pointer to Qortal recipient + // Skip expected 'redeem' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Qortal trade address dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to message sender dataByteBuffer.position(dataByteBuffer.position() + 8); + // Skip 'trade' message data offset for partner's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for partner's Qortal receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + // Skip pointer to message data dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); - // Qortal recipient (if any) - dataByteBuffer.get(addressBytes); + // Skip pointer to partner's receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); - // Trade offer timeout (AT 'timestamp' converted to Qortal block height) + /* End of constants / begin variables */ + + // Skip AT creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Partner's trade address (if present) + dataByteBuffer.get(addressBytes); + String qortalRecipient = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Potential lockTimeA (if in trade mode) + int lockTimeA = (int) dataByteBuffer.getLong(); + + // Potential lockTimeB (if in trade mode) + int lockTimeB = (int) dataByteBuffer.getLong(); + + // AT refund timeout (probably only useful for debugging) + int refundTimeout = (int) dataByteBuffer.getLong(); + + // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); - if (tradeRefundTimestamp != 0) { - tradeData.mode = CrossChainTradeData.Mode.TRADE; + // Skip last transaction timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip block timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary result + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message sender + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Skip message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message data + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Potential hash160 of secret A + byte[] hashOfSecretA = new byte[20]; + dataByteBuffer.get(hashOfSecretA); + dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes + + // Potential partner's Bitcoin PKH + byte[] partnerBitcoinPKH = new byte[20]; + dataByteBuffer.get(partnerBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes + + // Partner's receiving address (if present) + byte[] partnerReceivingAddress = new byte[25]; + dataByteBuffer.get(partnerReceivingAddress); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes + + // Trade AT's 'mode' + long modeValue = dataByteBuffer.getLong(); + Mode mode = Mode.valueOf((int) (modeValue & 0xffL)); + + /* End of variables */ + + if (mode != null && mode != Mode.OFFERING) { + tradeData.mode = mode; + tradeData.refundTimeout = refundTimeout; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; + tradeData.qortalPartnerAddress = qortalRecipient; + tradeData.hashOfSecretA = hashOfSecretA; + tradeData.partnerBitcoinPKH = partnerBitcoinPKH; + tradeData.lockTimeA = lockTimeA; + tradeData.lockTimeB = lockTimeB; - if (addressBytes[0] != 0) - tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); - - // We'll suggest half of trade timeout - CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings(); - - int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeRefundTimeout / ciyamAtSettings.minutesPerBlock); - - BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight); - if (blockData != null) { - tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch - tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeRefundTimeout / 2 * 60); - } + if (mode == Mode.REDEEMED) + tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); } else { - tradeData.mode = CrossChainTradeData.Mode.OFFER; + tradeData.mode = Mode.OFFERING; } return tradeData; } - public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) { - NetworkParameters params = BTC.getInstance().getNetworkParameters(); + /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); + } - for (byte[] rawTransaction : rawTransactions) { - Transaction transaction = new Transaction(params, rawTransaction); + /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ + public static OfferMessageData extractOfferMessageData(byte[] messageData) { + if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) + return null; - // Cycle through inputs, looking for one that spends our P2SH - for (TransactionInput input : transaction.getInputs()) { - Script scriptSig = input.getScriptSig(); - List scriptChunks = scriptSig.getChunks(); + OfferMessageData offerMessageData = new OfferMessageData(); + offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); + offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); - // Expected number of script chunks for redeem. Refund might not have the same number. - int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/; - if (scriptChunks.size() != expectedChunkCount) - continue; + return offerMessageData; + } - // We're expecting last chunk to contain the actual redeemScript - ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1); - byte[] redeemScriptBytes = lastChunk.data; + /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { + byte[] data = new byte[TRADE_MESSAGE_LENGTH]; + byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB); - // If non-push scripts, redeemScript will be null - if (redeemScriptBytes == null) - continue; + System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); + System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); + System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + return data; + } - if (!inputAddress.toString().equals(p2shAddress)) - // Input isn't spending our P2SH - continue; + /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ + public static byte[] buildCancelMessage(String creatorQortalAddress) { + byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); - byte[] secret = scriptChunks.get(0).data; - if (secret.length != BTCACCT.SECRET_LENGTH) - continue; + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); - return secret; - } + return data; + } + + /** Returns 'redeem' MESSAGE payload for trade partner/ to send to AT. */ + public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceivingAddress) { + byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; + byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(secretB, 0, data, 32, secretB.length); + System.arraycopy(qortalReceivingAddressBytes, 0, data, 64, qortalReceivingAddressBytes.length); + + return data; + } + + /** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ + public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) { + // lockTimeB is halfway between offerMessageTimesamp and lockTimeA + return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L); + } + + public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalPartnerAddress; + + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find 'redeem' message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != REDEEM_MESSAGE_LENGTH) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract both secretA & secretB + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + byte[] secretB = new byte[32]; + System.arraycopy(messageData, 32, secretB, 0, secretB.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + byte[] hashOfSecretB = Crypto.hash160(secretB); + if (!Arrays.equals(hashOfSecretB, crossChainTradeData.hashOfSecretB)) + continue; + + return secretA; } return null; diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java new file mode 100644 index 00000000..8a0fa546 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -0,0 +1,236 @@ +package org.qortal.crosschain; + +import java.util.List; +import java.util.function.Function; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.Transaction.SigHash; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.script.ScriptChunk; +import org.bitcoinj.script.ScriptOpCodes; +import org.qortal.crypto.Crypto; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +public class BTCP2SH { + + public static final int SECRET_LENGTH = 32; + public static final int MIN_LOCKTIME = 1500000000; + + /* + * OP_TUCK (to copy public key to before signature) + * OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails) + * OP_HASH160 (convert public key to PKH) + * OP_DUP (duplicate PKH) + * OP_EQUAL (does PKH match refund PKH?) + * OP_IF + * OP_DROP (no need for duplicate PKH) + * + * OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is so script passes) + * OP_ELSE + * OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails) + * OP_HASH160 (hash secret) + * OP_EQUAL (do hashes of secrets match? if true, script passes else script fails) + * OP_ENDIF + */ + + private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes) + private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes) + private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes) + private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes) + private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF + + /** + * Returns Bitcoin redeemScript used for cross-chain trading. + *

+ * See comments in {@link BTCP2SH} for more details. + * + * @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes + * @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund + * @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key + * @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds + * @return + */ + public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) { + return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)), + redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5); + } + + /** + * Builds a custom transaction to spend P2SH. + * + * @param amount output amount, should be total of input amounts, less miner fees + * @param spendKey key for signing transaction, and also where funds are 'sent' (output) + * @param fundingOutput output from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param lockTime (optional) transaction nLockTime, used in refund scenario + * @param scriptSigBuilder function for building scriptSig using transaction input signature + * @param outputPublicKeyHash PKH used to create P2PKH output + * @return Signed Bitcoin transaction for spending P2SH + */ + public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, + Long lockTime, Function scriptSigBuilder, byte[] outputPublicKeyHash) { + NetworkParameters params = BTC.getInstance().getNetworkParameters(); + + Transaction transaction = new Transaction(params); + transaction.setVersion(2); + + // Output is back to P2SH funder + transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash)); + + for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { + TransactionOutput fundingOutput = fundingOutputs.get(inputIndex); + + // Input (without scriptSig prior to signing) + TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor()); + if (lockTime != null) + input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF + else + input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF + transaction.addInput(input); + } + + // Set locktime after inputs added but before input signatures are generated + if (lockTime != null) + transaction.setLockTime(lockTime); + + for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { + // Generate transaction signature for input + final boolean anyoneCanPay = false; + TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay); + + // Calculate transaction signature + byte[] txSigBytes = txSig.encodeToBitcoin(); + + // Build scriptSig using lambda and tx signature + Script scriptSig = scriptSigBuilder.apply(txSigBytes); + + // Set input scriptSig + transaction.getInput(inputIndex).setScriptSig(scriptSig); + } + + return transaction; + } + + /** + * Returns signed Bitcoin transaction claiming refund from P2SH address. + * + * @param refundAmount refund amount, should be total of input amounts, less miner fees + * @param refundKey key for signing transaction, and also where refund is 'sent' (output) + * @param fundingOutput output from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript + * @return Signed Bitcoin transaction for refunding P2SH + */ + public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime) { + Function refundSigScriptBuilder = (txSigBytes) -> { + // Build scriptSig with... + ScriptBuilder scriptBuilder = new ScriptBuilder(); + + // transaction signature + scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); + + // redeem public key + byte[] refundPubKey = refundKey.getPubKey(); + scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey)); + + // redeem script + scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); + + return scriptBuilder.build(); + }; + + // Send funds back to funding address + return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash()); + } + + /** + * Returns signed Bitcoin transaction redeeming funds from P2SH address. + * + * @param redeemAmount redeem amount, should be total of input amounts, less miner fees + * @param redeemKey key for signing transaction, and also where funds are 'sent' (output) + * @param fundingOutput output from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param secret actual 32-byte secret used when building redeemScript + * @param receivingAccountInfo Bitcoin PKH used for output + * @return Signed Bitcoin transaction for redeeming P2SH + */ + public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) { + Function redeemSigScriptBuilder = (txSigBytes) -> { + // Build scriptSig with... + ScriptBuilder scriptBuilder = new ScriptBuilder(); + + // secret + scriptBuilder.addChunk(new ScriptChunk(secret.length, secret)); + + // transaction signature + scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); + + // redeem public key + byte[] redeemPubKey = redeemKey.getPubKey(); + scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey)); + + // redeem script + scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); + + return scriptBuilder.build(); + }; + + return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo); + } + + /** Returns 'secret', if any, given list of raw bitcoin transactions. */ + public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) { + NetworkParameters params = BTC.getInstance().getNetworkParameters(); + + for (byte[] rawTransaction : rawTransactions) { + Transaction transaction = new Transaction(params, rawTransaction); + + // Cycle through inputs, looking for one that spends our P2SH + for (TransactionInput input : transaction.getInputs()) { + Script scriptSig = input.getScriptSig(); + List scriptChunks = scriptSig.getChunks(); + + // Expected number of script chunks for redeem. Refund might not have the same number. + int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/; + if (scriptChunks.size() != expectedChunkCount) + continue; + + // We're expecting last chunk to contain the actual redeemScript + ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1); + byte[] redeemScriptBytes = lastChunk.data; + + // If non-push scripts, redeemScript will be null + if (redeemScriptBytes == null) + continue; + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!inputAddress.toString().equals(p2shAddress)) + // Input isn't spending our P2SH + continue; + + byte[] secret = scriptChunks.get(0).data; + if (secret.length != BTCP2SH.SECRET_LENGTH) + continue; + + return secret; + } + } + + return null; + } + +} diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 41c3d99d..8b10e52a 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -25,11 +25,11 @@ import org.json.simple.JSONObject; import org.json.simple.JSONValue; import org.qortal.crypto.Crypto; import org.qortal.crypto.TrustlessSSLSocketFactory; -import org.qortal.utils.Pair; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; +/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */ public class ElectrumX { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); @@ -93,7 +93,21 @@ public class ElectrumX { private ElectrumX(String bitcoinNetwork) { switch (bitcoinNetwork) { case "MAIN": - servers.addAll(Arrays.asList()); + servers.addAll(Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("tardis.bauerj.eu", Server.ConnectionType.SSL, 50002), + new Server("rbx.curalle.ovh", Server.ConnectionType.SSL, 50002), + new Server("quick.electumx.live", Server.ConnectionType.SSL, 50002), + new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002), + new Server("electrumx.ddns.net", Server.ConnectionType.SSL, 50002), + new Server("electrumx.ml", Server.ConnectionType.SSL, 50002), + new Server("electrum.eff.ro", Server.ConnectionType.SSL, 50002), + new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512), + new Server("E-X.not.fyi", Server.ConnectionType.SSL, 50002), + new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002), + new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 50001), + new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001))); break; case "TEST3": @@ -119,6 +133,7 @@ public class ElectrumX { rpc("server.banner"); } + /** Returns ElectrumX instance linked to passed Bitcoin network, one of "MAIN", "TEST3" or "REGTEST". */ public static synchronized ElectrumX getInstance(String bitcoinNetwork) { if (!instances.containsKey(bitcoinNetwork)) instances.put(bitcoinNetwork, new ElectrumX(bitcoinNetwork)); @@ -129,16 +144,26 @@ public class ElectrumX { // Methods for use by other classes public Integer getCurrentHeight() { - JSONObject blockJson = (JSONObject) this.rpc("blockchain.headers.subscribe"); - if (blockJson == null || !blockJson.containsKey("height")) + Object blockObj = this.rpc("blockchain.headers.subscribe"); + if (!(blockObj instanceof JSONObject)) + return null; + + JSONObject blockJson = (JSONObject) blockObj; + + if (!blockJson.containsKey("height")) return null; return ((Long) blockJson.get("height")).intValue(); } public List getBlockHeaders(int startHeight, long count) { - JSONObject blockJson = (JSONObject) this.rpc("blockchain.block.headers", startHeight, count); - if (blockJson == null || !blockJson.containsKey("count") || !blockJson.containsKey("hex")) + Object blockObj = this.rpc("blockchain.block.headers", startHeight, count); + if (!(blockObj instanceof JSONObject)) + return null; + + JSONObject blockJson = (JSONObject) blockObj; + + if (!blockJson.containsKey("count") || !blockJson.containsKey("hex")) return null; Long returnedCount = (Long) blockJson.get("count"); @@ -155,57 +180,87 @@ public class ElectrumX { return rawBlockHeaders; } + /** Returns confirmed balance, based on passed payment script, or null if there was an error or no known balance. */ public Long getBalance(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); - JSONObject balanceJson = (JSONObject) this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); - if (balanceJson == null || !balanceJson.containsKey("confirmed")) + Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); + if (!(balanceObj instanceof JSONObject)) + return null; + + JSONObject balanceJson = (JSONObject) balanceObj; + + if (!balanceJson.containsKey("confirmed")) return null; return (Long) balanceJson.get("confirmed"); } - public List> getUnspentOutputs(byte[] script) { + /** Unspent output info as returned by ElectrumX network. */ + public static class UnspentOutput { + public final byte[] hash; + public final int index; + public final int height; + public final long value; + + public UnspentOutput(byte[] hash, int index, int height, long value) { + this.hash = hash; + this.index = index; + this.height = height; + this.value = value; + } + } + + /** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */ + public List getUnspentOutputs(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); - JSONArray unspentJson = (JSONArray) this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); - if (unspentJson == null) + Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); + if (!(unspentJson instanceof JSONArray)) return null; - List> unspentOutputs = new ArrayList<>(); - for (Object rawUnspent : unspentJson) { + List unspentOutputs = new ArrayList<>(); + for (Object rawUnspent : (JSONArray) unspentJson) { JSONObject unspent = (JSONObject) rawUnspent; + int height = ((Long) unspent.get("height")).intValue(); + // We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0) + if (height <= 0) + continue; + byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); int outputIndex = ((Long) unspent.get("tx_pos")).intValue(); + long value = (Long) unspent.get("value"); - unspentOutputs.add(new Pair<>(txHash, outputIndex)); + unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); } return unspentOutputs; } + /** Returns raw transaction for passed transaction hash, or null if not found. */ public byte[] getRawTransaction(byte[] txHash) { - String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); - if (rawTransactionHex == null) + Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); + if (!(rawTransactionHex instanceof String)) return null; - return HashCode.fromString(rawTransactionHex).asBytes(); + return HashCode.fromString((String) rawTransactionHex).asBytes(); } + /** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */ public List getAddressTransactions(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); - JSONArray transactionsJson = (JSONArray) this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); - if (transactionsJson == null) + Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); + if (!(transactionsJson instanceof JSONArray)) return null; List rawTransactions = new ArrayList<>(); - for (Object rawTransactionInfo : transactionsJson) { + for (Object rawTransactionInfo : (JSONArray) transactionsJson) { JSONObject transactionInfo = (JSONObject) rawTransactionInfo; // We only want confirmed transactions @@ -223,6 +278,7 @@ public class ElectrumX { return rawTransactions; } + /** Returns true if raw transaction successfully broadcast. */ public boolean broadcastTransaction(byte[] transactionBytes) { Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString()); if (rawBroadcastResult == null) @@ -235,14 +291,15 @@ public class ElectrumX { // Class-private utility methods + /** Query current server for its list of peer servers, and return those we can parse. */ private Set serverPeersSubscribe() { Set newServers = new HashSet<>(); - JSONArray peers = (JSONArray) this.connectedRpc("server.peers.subscribe"); - if (peers == null) + Object peers = this.connectedRpc("server.peers.subscribe"); + if (!(peers instanceof JSONArray)) return newServers; - for (Object rawPeer : peers) { + for (Object rawPeer : (JSONArray) peers) { JSONArray peer = (JSONArray) rawPeer; if (peer.size() < 3) continue; @@ -287,6 +344,7 @@ public class ElectrumX { return newServers; } + /** Return output from RPC call, with automatic reconnection to different server if needed. */ private synchronized Object rpc(String method, Object...params) { while (haveConnection()) { Object response = connectedRpc(method, params); @@ -305,6 +363,7 @@ public class ElectrumX { return null; } + /** Returns true if we have, or create, a connection to an ElectrumX server. */ private boolean haveConnection() { if (this.currentServer != null) return true; @@ -377,10 +436,12 @@ public class ElectrumX { if (response.isEmpty()) return null; - JSONObject responseJson = (JSONObject) JSONValue.parse(response); - if (responseJson == null) + Object responseObj = JSONValue.parse(response); + if (!(responseObj instanceof JSONObject)) return null; + JSONObject responseJson = (JSONObject) responseObj; + return responseJson.get("result"); } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 8c9b6602..f445f58e 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -4,14 +4,14 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import org.qortal.crosschain.BTCACCT; + import io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeData { - public enum Mode { OFFER, TRADE }; - // Properties @Schema(description = "AT's Qortal address") @@ -20,32 +20,40 @@ public class CrossChainTradeData { @Schema(description = "AT creator's Qortal address") public String qortalCreator; + @Schema(description = "AT creator's Qortal trade address") + public String qortalCreatorTradeAddress; + + @Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)") + public byte[] creatorBitcoinPKH; + @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") public long creationTimestamp; + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + public int tradeTimeout; + @Schema(description = "AT's current QORT balance") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long qortBalance; - @Schema(description = "HASH160 of 32-byte secret") - public byte[] secretHash; + @Schema(description = "HASH160 of 32-byte secret-A") + public byte[] hashOfSecretA; - @Schema(description = "Initial QORT payment that will be sent to Qortal trade partner") - @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - public long initialPayout; + @Schema(description = "HASH160 of 32-byte secret-B") + public byte[] hashOfSecretB; @Schema(description = "Final QORT payment that will be sent to Qortal trade partner") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) - public long redeemPayout; + public long qortAmount; @Schema(description = "Trade partner's Qortal address (trade begins when this is set)") - public String qortalRecipient; + public String qortalPartnerAddress; @Schema(description = "Timestamp when AT switched to trade mode") public Long tradeModeTimestamp; - @Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)") - public long tradeRefundTimeout; + @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") + public Integer refundTimeout; @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") public Integer tradeRefundHeight; @@ -54,10 +62,19 @@ public class CrossChainTradeData { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long expectedBitcoin; - public Mode mode; + public BTCACCT.Mode mode; - @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout") - public Integer lockTime; + @Schema(description = "Suggested Bitcoin P2SH-A nLockTime based on trade timeout") + public Integer lockTimeA; + + @Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout") + public Integer lockTimeB; + + @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") + public byte[] partnerBitcoinPKH; + + @Schema(description = "Trade partner's Qortal receiving address") + public String qortalPartnerReceivingAddress; // Constructors diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java new file mode 100644 index 00000000..0f57845d --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -0,0 +1,193 @@ +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; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +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); + + 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; + + private String creatorAddress; + private String atAddress; + + private long timestamp; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + private byte[] tradeNativePublicKey; + private byte[] tradeNativePublicKeyHash; + String tradeNativeAddress; + + private byte[] secret; + private byte[] hashOfSecret; + + private byte[] tradeForeignPublicKey; + private byte[] tradeForeignPublicKeyHash; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long bitcoinAmount; + + // Never expose this via API + @XmlTransient + @Schema(hidden = true) + private String xprv58; + + private byte[] lastTransactionSignature; + + private Integer lockTimeA; + + // Could be Bitcoin or Qortal... + private byte[] receivingAccountInfo; + + protected TradeBotData() { + /* JAXB */ + } + + public TradeBotData(byte[] tradePrivateKey, State tradeState, 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) { + this.tradePrivateKey = tradePrivateKey; + this.tradeState = tradeState; + this.creatorAddress = creatorAddress; + this.atAddress = atAddress; + this.timestamp = timestamp; + this.qortAmount = qortAmount; + this.tradeNativePublicKey = tradeNativePublicKey; + this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; + this.tradeNativeAddress = tradeNativeAddress; + this.secret = secret; + this.hashOfSecret = hashOfSecret; + this.tradeForeignPublicKey = tradeForeignPublicKey; + this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; + this.bitcoinAmount = bitcoinAmount; + this.xprv58 = xprv58; + this.lastTransactionSignature = lastTransactionSignature; + this.lockTimeA = lockTimeA; + this.receivingAccountInfo = receivingAccountInfo; + } + + public byte[] getTradePrivateKey() { + return this.tradePrivateKey; + } + + public State getState() { + return this.tradeState; + } + + public void setState(State state) { + this.tradeState = state; + } + + public String getCreatorAddress() { + return this.creatorAddress; + } + + public String getAtAddress() { + return this.atAddress; + } + + public void setAtAddress(String atAddress) { + this.atAddress = atAddress; + } + + public long getTimestamp() { + return this.timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public byte[] getTradeNativePublicKey() { + return this.tradeNativePublicKey; + } + + public byte[] getTradeNativePublicKeyHash() { + return this.tradeNativePublicKeyHash; + } + + public String getTradeNativeAddress() { + return this.tradeNativeAddress; + } + + public byte[] getSecret() { + return this.secret; + } + + public byte[] getHashOfSecret() { + return this.hashOfSecret; + } + + public byte[] getTradeForeignPublicKey() { + return this.tradeForeignPublicKey; + } + + public byte[] getTradeForeignPublicKeyHash() { + return this.tradeForeignPublicKeyHash; + } + + public long getBitcoinAmount() { + return this.bitcoinAmount; + } + + public String getXprv58() { + return this.xprv58; + } + + public byte[] getLastTransactionSignature() { + return this.lastTransactionSignature; + } + + public void setLastTransactionSignature(byte[] lastTransactionSignature) { + this.lastTransactionSignature = lastTransactionSignature; + } + + public Integer getLockTimeA() { + return this.lockTimeA; + } + + public void setLockTimeA(Integer lockTimeA) { + this.lockTimeA = lockTimeA; + } + + public byte[] getReceivingAccountInfo() { + return this.receivingAccountInfo; + } + +} diff --git a/src/main/java/org/qortal/event/Event.java b/src/main/java/org/qortal/event/Event.java new file mode 100644 index 00000000..0c97522c --- /dev/null +++ b/src/main/java/org/qortal/event/Event.java @@ -0,0 +1,5 @@ +package org.qortal.event; + +public interface Event { + +} diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java new file mode 100644 index 00000000..e0014a20 --- /dev/null +++ b/src/main/java/org/qortal/event/EventBus.java @@ -0,0 +1,33 @@ +package org.qortal.event; + +import java.util.ArrayList; +import java.util.List; + +public enum EventBus { + INSTANCE; + + private static final List LISTENERS = new ArrayList<>(); + + public void addListener(Listener newListener) { + synchronized (LISTENERS) { + LISTENERS.add(newListener); + } + } + + public void removeListener(Listener listener) { + synchronized (LISTENERS) { + LISTENERS.remove(listener); + } + } + + public void notify(Event event) { + List clonedListeners; + + synchronized (LISTENERS) { + clonedListeners = new ArrayList<>(LISTENERS); + } + + for (Listener listener : clonedListeners) + listener.listen(event); + } +} diff --git a/src/main/java/org/qortal/event/Listener.java b/src/main/java/org/qortal/event/Listener.java new file mode 100644 index 00000000..cb1668bf --- /dev/null +++ b/src/main/java/org/qortal/event/Listener.java @@ -0,0 +1,6 @@ +package org.qortal.event; + +@FunctionalInterface +public interface Listener { + void listen(Event event); +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index affbaf18..f3c2b16d 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -15,6 +15,9 @@ public interface ATRepository { /** Returns where AT with passed address exists in repository */ public boolean exists(String atAddress) throws DataException; + /** Returns AT creator's public key, or null if not found */ + public byte[] getCreatorPublicKey(String atAddress) throws DataException; + /** Returns list of executable ATs, empty if none found */ public List getAllExecutableATs() throws DataException; @@ -54,6 +57,24 @@ public interface ATRepository { */ public ATStateData getLatestATState(String atAddress) throws DataException; + /** + * Returns final ATStateData for ATs matching codeHash (required) + * and specific data segment value (optional). + *

+ * If searching for specific data segment value, both dataByteOffset + * and expectedValue need to be non-null. + *

+ * Note that dataByteOffset starts from 0 and will typically be + * a multiple of MachineState.VALUE_SIZE, which is usually 8: + * width of a long. + *

+ * Although expectedValue, if provided, is natively an unsigned long, + * the data segment comparison is done via unsigned hex string. + */ + public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException; + /** * Returns all ATStateData for a given block height. *

@@ -88,4 +109,28 @@ public interface ATRepository { /** Delete state data for all ATs at this height */ public void deleteATStates(int height) throws DataException; + // Finding transactions for ATs to process + + static class NextTransactionInfo { + public final int height; + public final int sequence; + public final byte[] signature; + + public NextTransactionInfo(int height, int sequence, byte[] signature) { + this.height = height; + this.sequence = sequence; + this.signature = signature; + } + } + + /** + * Find next transaction for AT to process. + *

+ * @param recipient AT address + * @param height starting height + * @param sequence starting sequence + * @return next transaction info, or null if none found + */ + public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException; + } diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 8104edef..078128f6 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -61,6 +61,15 @@ public interface BlockRepository { */ public int getHeightFromTimestamp(long timestamp) throws DataException; + /** + * Returns block timestamp for a given height. + * + * @param height + * @return timestamp, or 0 if height is out of bounds. + * @throws DataException + */ + public long getTimestampFromHeight(int height) throws DataException; + /** * Return highest block height from repository. * diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java new file mode 100644 index 00000000..cee1dc69 --- /dev/null +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -0,0 +1,18 @@ +package org.qortal.repository; + +import java.util.List; + +import org.qortal.data.crosschain.TradeBotData; + +public interface CrossChainRepository { + + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException; + + public List getAllTradeBotData() throws DataException; + + public void save(TradeBotData tradeBotData) throws DataException; + + /** Delete trade-bot states using passed private key. */ + public int delete(byte[] tradePrivateKey) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 5c28253b..aecf4ef0 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -14,6 +14,8 @@ public interface Repository extends AutoCloseable { public ChatRepository getChatRepository(); + public CrossChainRepository getCrossChainRepository(); + public GroupRepository getGroupRepository(); public NameRepository getNameRepository(); diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 56e51be1..38856d24 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -6,6 +6,7 @@ import java.util.Map; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.data.group.GroupApprovalData; import org.qortal.data.transaction.GroupApprovalTransactionData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.data.transaction.TransferAssetTransactionData; import org.qortal.transaction.Transaction.TransactionType; @@ -107,6 +108,18 @@ public interface TransactionRepository { */ public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException; + /** + * Returns list of MESSAGE transaction data matching recipient. + * @param recipient + * @param limit + * @param offset + * @param reverse + * @return + * @throws DataException + */ + public List getMessagesByRecipient(String recipient, + Integer limit, Integer offset, Boolean reverse) throws DataException; + /** * Returns list of transactions relating to specific asset ID. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f6de4fb4..e223b760 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -68,6 +68,20 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public byte[] getCreatorPublicKey(String atAddress) throws DataException { + String sql = "SELECT creator FROM ATs WHERE AT_address = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) { + if (resultSet == null) + return null; + + return resultSet.getBytes(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch AT creator's public key from repository", e); + } + } + @Override public List getAllExecutableATs() throws DataException { String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, " @@ -273,6 +287,78 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, + Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial " + + "FROM ATs " + + "CROSS JOIN LATERAL(" + + "SELECT height, created_when, state_data, state_hash, fees, is_initial " + + "FROM ATStates " + + "WHERE ATStates.AT_address = ATs.AT_address " + + "ORDER BY height DESC " + + "LIMIT 1" + + ") AS FinalATStates " + + "WHERE code_hash = ? "); + + List bindParams = new ArrayList<>(); + bindParams.add(codeHash); + + if (isFinished != null) { + sql.append("AND is_finished = ?"); + bindParams.add(isFinished); + } + + if (dataByteOffset != null && expectedValue != null) { + sql.append("AND RAWTOHEX(SUBSTRING(state_data FROM ? FOR 8)) = ? "); + + // We convert our long to hex Java-side to control endian + String expectedHexValue = String.format("%016x", expectedValue); // left-zero-padding and conversion + + // SQL binary data offsets start at 1 + bindParams.add(dataByteOffset + 1); + bindParams.add(expectedHexValue); + } + + if (minimumFinalHeight != null) { + sql.append("AND height >= "); + sql.append(minimumFinalHeight); + } + + sql.append(" ORDER BY height "); + if (reverse != null && reverse) + sql.append("DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List atStates = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return atStates; + + do { + String atAddress = resultSet.getString(1); + int height = resultSet.getInt(2); + long created = resultSet.getLong(3); + byte[] stateData = resultSet.getBytes(4); // Actually BLOB + byte[] stateHash = resultSet.getBytes(5); + long fees = resultSet.getLong(6); + boolean isInitial = resultSet.getBoolean(7); + + ATStateData atStateData = new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial); + + atStates.add(atStateData); + } while (resultSet.next()); + + return atStates; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching AT states from repository", e); + } + } + @Override public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " @@ -341,4 +427,40 @@ public class HSQLDBATRepository implements ATRepository { } } + // Finding transactions for ATs to process + + public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException { + // We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT + + String sql = "SELECT height, sequence, Transactions.signature " + + "FROM (" + + "SELECT signature FROM PaymentTransactions WHERE recipient = ? " + + "UNION " + + "SELECT signature FROM MessageTransactions WHERE recipient = ? " + + "UNION " + + "SELECT signature FROM ATTransactions WHERE recipient = ?" + + ") AS Transactions " + + "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature " + + "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature " + + "WHERE (height > ? OR (height = ? AND sequence > ?)) " + + "ORDER BY height ASC, sequence ASC " + + "LIMIT 1"; + + Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence }; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams)) { + if (resultSet == null) + return null; + + int nextHeight = resultSet.getInt(1); + int nextSequence = resultSet.getInt(2); + byte[] nextSignature = resultSet.getBytes(3); + + return new NextTransactionInfo(nextHeight, nextSequence, nextSignature); + } catch (SQLException e) { + throw new DataException("Unable to find next transaction to AT from repository", e); + } + + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index ccca6832..0486bdc7 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -132,6 +132,20 @@ public class HSQLDBBlockRepository implements BlockRepository { } } + @Override + public long getTimestampFromHeight(int height) throws DataException { + String sql = "SELECT minted_when FROM Blocks WHERE height = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, height)) { + if (resultSet == null) + return 0; + + return resultSet.getLong(1); + } catch (SQLException e) { + throw new DataException("Error obtaining block timestamp by height from repository", e); + } + } + @Override public int getBlockchainHeight() throws DataException { String sql = "SELECT height FROM Blocks ORDER BY height DESC LIMIT 1"; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java new file mode 100644 index 00000000..589ca0a4 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -0,0 +1,165 @@ +package org.qortal.repository.hsqldb; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.CrossChainRepository; +import org.qortal.repository.DataException; + +public class HSQLDBCrossChainRepository implements CrossChainRepository { + + protected HSQLDBRepository repository; + + public HSQLDBCrossChainRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + @Override + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { + String sql = "SELECT trade_state, 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 " + + "FROM TradeBotStates " + + "WHERE trade_private_key = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, tradePrivateKey)) { + if (resultSet == null) + return null; + + int tradeStateValue = resultSet.getInt(1); + TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); + if (tradeState == null) + throw new DataException("Illegal trade-bot trade-state fetched from repository"); + + String 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); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + byte[] receivingAccountInfo = resultSet.getBytes(17); + + return new TradeBotData(tradePrivateKey, tradeState, + creatorAddress, atAddress, timestamp, qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading state from repository", e); + } + } + + @Override + public List getAllTradeBotData() throws DataException { + String sql = "SELECT trade_private_key, trade_state, 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 " + + "FROM TradeBotStates"; + + List allTradeBotData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return allTradeBotData; + + 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); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + byte[] receivingAccountInfo = resultSet.getBytes(18); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, + creatorAddress, atAddress, timestamp, qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + allTradeBotData.add(tradeBotData); + } while (resultSet.next()); + + return allTradeBotData; + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading states from repository", e); + } + } + + @Override + public void save(TradeBotData tradeBotData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates"); + + saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) + .bind("trade_state", tradeBotData.getState().value) + .bind("creator_address", tradeBotData.getCreatorAddress()) + .bind("at_address", tradeBotData.getAtAddress()) + .bind("updated_when", tradeBotData.getTimestamp()) + .bind("qort_amount", tradeBotData.getQortAmount()) + .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) + .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) + .bind("trade_native_address", tradeBotData.getTradeNativeAddress()) + .bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret()) + .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) + .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) + .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) + .bind("xprv58", tradeBotData.getXprv58()) + .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) + .bind("locktime_a", tradeBotData.getLockTimeA()) + .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save trade bot data into repository", e); + } + } + + @Override + public int delete(byte[] tradePrivateKey) throws DataException { + try { + return this.repository.delete("TradeBotStates", "trade_private_key = ?", tradePrivateKey); + } catch (SQLException e) { + throw new DataException("Unable to delete trade-bot states from repository", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c4751f44..10ab2d2c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -4,9 +4,14 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.utils.Base58; + +import com.google.common.hash.HashCode; public class HSQLDBDatabaseUpdates { @@ -618,6 +623,22 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE TABLE PublicizeTransactions (signature Signature, nonce INT NOT NULL, " + TRANSACTION_KEYS + ")"); break; + case 20: + // Trade bot + 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, " + + "trade_native_address QortalAddress NOT NULL, secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, " + + "trade_foreign_public_key VARBINARY(33) NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " + + "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, " + + "receiving_account_info VARBINARY(32) NOT NULL, PRIMARY KEY (trade_private_key))"); + break; + + case 21: + // AT functionality index + stmt.execute("CREATE INDEX IF NOT EXISTS ATCodeHashIndex ON ATs (code_hash, is_finished)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 9c0ad9ab..dbee6cc0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -32,6 +32,7 @@ import org.qortal.repository.ArbitraryRepository; import org.qortal.repository.AssetRepository; import org.qortal.repository.BlockRepository; import org.qortal.repository.ChatRepository; +import org.qortal.repository.CrossChainRepository; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.NameRepository; @@ -115,6 +116,11 @@ public class HSQLDBRepository implements Repository { return new HSQLDBChatRepository(this); } + @Override + public CrossChainRepository getCrossChainRepository() { + return new HSQLDBCrossChainRepository(this); + } + @Override public GroupRepository getGroupRepository() { return new HSQLDBGroupRepository(this); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 0ab6ed94..bf9c88aa 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -19,6 +19,7 @@ import org.qortal.data.PaymentData; import org.qortal.data.group.GroupApprovalData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.GroupApprovalTransactionData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.data.transaction.TransferAssetTransactionData; import org.qortal.repository.DataException; @@ -630,6 +631,43 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public List getMessagesByRecipient(String recipient, + Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT signature from MessageTransactions " + + "JOIN Transactions USING (signature) " + + "JOIN BlockTransactions ON transaction_signature = signature " + + "WHERE recipient = ?"); + + sql.append("ORDER BY Transactions.created_when"); + sql.append((reverse == null || !reverse) ? " ASC" : " DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List messageTransactionsData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), recipient)) { + if (resultSet == null) + return messageTransactionsData; + + do { + byte[] signature = resultSet.getBytes(1); + + TransactionData transactionData = this.fromSignature(signature); + if (transactionData == null || transactionData.getType() != TransactionType.MESSAGE) + return null; + + messageTransactionsData.add((MessageTransactionData) transactionData); + } while (resultSet.next()); + + return messageTransactionsData; + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot messages from repository", e); + } + } + + @Override public List getAssetTransactions(long assetId, ConfirmationStatus confirmationStatus, Integer limit, Integer offset, Boolean reverse) throws DataException { diff --git a/src/main/java/org/qortal/transaction/DeployAtTransaction.java b/src/main/java/org/qortal/transaction/DeployAtTransaction.java index 46ad9e3e..fea63cde 100644 --- a/src/main/java/org/qortal/transaction/DeployAtTransaction.java +++ b/src/main/java/org/qortal/transaction/DeployAtTransaction.java @@ -26,7 +26,7 @@ import com.google.common.base.Utf8; public class DeployAtTransaction extends Transaction { // Properties - private DeployAtTransactionData deployATTransactionData; + private DeployAtTransactionData deployAtTransactionData; // Other useful constants public static final int MAX_NAME_SIZE = 200; @@ -40,31 +40,31 @@ public class DeployAtTransaction extends Transaction { public DeployAtTransaction(Repository repository, TransactionData transactionData) { super(repository, transactionData); - this.deployATTransactionData = (DeployAtTransactionData) this.transactionData; + this.deployAtTransactionData = (DeployAtTransactionData) this.transactionData; } // More information @Override public List getRecipientAddresses() throws DataException { - return Collections.singletonList(this.deployATTransactionData.getAtAddress()); + return Collections.singletonList(this.deployAtTransactionData.getAtAddress()); } /** Returns AT version from the header bytes */ private short getVersion() { - byte[] creationBytes = deployATTransactionData.getCreationBytes(); + byte[] creationBytes = deployAtTransactionData.getCreationBytes(); return (short) ((creationBytes[0] << 8) | (creationBytes[1] & 0xff)); // Big-endian } /** Make sure deployATTransactionData has an ATAddress */ - private void ensureATAddress() throws DataException { - if (this.deployATTransactionData.getAtAddress() != null) + public static void ensureATAddress(DeployAtTransactionData deployAtTransactionData) throws DataException { + if (deployAtTransactionData.getAtAddress() != null) return; // Use transaction transformer try { - String atAddress = Crypto.toATAddress(TransactionTransformer.toBytesForSigning(this.deployATTransactionData)); - this.deployATTransactionData.setAtAddress(atAddress); + String atAddress = Crypto.toATAddress(TransactionTransformer.toBytesForSigning(deployAtTransactionData)); + deployAtTransactionData.setAtAddress(atAddress); } catch (TransformationException e) { throw new DataException("Unable to generate AT address"); } @@ -73,9 +73,9 @@ public class DeployAtTransaction extends Transaction { // Navigation public Account getATAccount() throws DataException { - ensureATAddress(); + ensureATAddress(this.deployAtTransactionData); - return new Account(this.repository, this.deployATTransactionData.getAtAddress()); + return new Account(this.repository, this.deployAtTransactionData.getAtAddress()); } // Processing @@ -83,30 +83,30 @@ public class DeployAtTransaction extends Transaction { @Override public ValidationResult isValid() throws DataException { // Check name size bounds - int nameLength = Utf8.encodedLength(this.deployATTransactionData.getName()); + int nameLength = Utf8.encodedLength(this.deployAtTransactionData.getName()); if (nameLength < 1 || nameLength > MAX_NAME_SIZE) return ValidationResult.INVALID_NAME_LENGTH; // Check description size bounds - int descriptionlength = Utf8.encodedLength(this.deployATTransactionData.getDescription()); + int descriptionlength = Utf8.encodedLength(this.deployAtTransactionData.getDescription()); if (descriptionlength < 1 || descriptionlength > MAX_DESCRIPTION_SIZE) return ValidationResult.INVALID_DESCRIPTION_LENGTH; // Check AT-type size bounds - int atTypeLength = Utf8.encodedLength(this.deployATTransactionData.getAtType()); + int atTypeLength = Utf8.encodedLength(this.deployAtTransactionData.getAtType()); if (atTypeLength < 1 || atTypeLength > MAX_AT_TYPE_SIZE) return ValidationResult.INVALID_AT_TYPE_LENGTH; // Check tags size bounds - int tagsLength = Utf8.encodedLength(this.deployATTransactionData.getTags()); + int tagsLength = Utf8.encodedLength(this.deployAtTransactionData.getTags()); if (tagsLength < 1 || tagsLength > MAX_TAGS_SIZE) return ValidationResult.INVALID_TAGS_LENGTH; // Check amount is positive - if (this.deployATTransactionData.getAmount() <= 0) + if (this.deployAtTransactionData.getAmount() <= 0) return ValidationResult.NEGATIVE_AMOUNT; - long assetId = this.deployATTransactionData.getAssetId(); + long assetId = this.deployAtTransactionData.getAssetId(); AssetData assetData = this.repository.getAssetRepository().fromAssetId(assetId); // Check asset even exists if (assetData == null) @@ -117,7 +117,7 @@ public class DeployAtTransaction extends Transaction { return ValidationResult.ASSET_NOT_SPENDABLE; // Check asset amount is integer if asset is not divisible - if (!assetData.isDivisible() && this.deployATTransactionData.getAmount() % Amounts.MULTIPLIER != 0) + if (!assetData.isDivisible() && this.deployAtTransactionData.getAmount() % Amounts.MULTIPLIER != 0) return ValidationResult.INVALID_AMOUNT; Account creator = this.getCreator(); @@ -125,15 +125,15 @@ public class DeployAtTransaction extends Transaction { // Check creator has enough funds if (assetId == Asset.QORT) { // Simple case: amount and fee both in QORT - long minimumBalance = this.deployATTransactionData.getFee() + this.deployATTransactionData.getAmount(); + long minimumBalance = this.deployAtTransactionData.getFee() + this.deployAtTransactionData.getAmount(); if (creator.getConfirmedBalance(Asset.QORT) < minimumBalance) return ValidationResult.NO_BALANCE; } else { - if (creator.getConfirmedBalance(Asset.QORT) < this.deployATTransactionData.getFee()) + if (creator.getConfirmedBalance(Asset.QORT) < this.deployAtTransactionData.getFee()) return ValidationResult.NO_BALANCE; - if (creator.getConfirmedBalance(assetId) < this.deployATTransactionData.getAmount()) + if (creator.getConfirmedBalance(assetId) < this.deployAtTransactionData.getAmount()) return ValidationResult.NO_BALANCE; } @@ -142,12 +142,12 @@ public class DeployAtTransaction extends Transaction { return ValidationResult.INVALID_CREATION_BYTES; // Check creation bytes are valid (for v2+) - this.ensureATAddress(); + ensureATAddress(this.deployAtTransactionData); // Just enough AT data to allow API to query initial balances, etc. - String atAddress = this.deployATTransactionData.getAtAddress(); - byte[] creatorPublicKey = this.deployATTransactionData.getCreatorPublicKey(); - long creation = this.deployATTransactionData.getTimestamp(); + String atAddress = this.deployAtTransactionData.getAtAddress(); + byte[] creatorPublicKey = this.deployAtTransactionData.getCreatorPublicKey(); + long creation = this.deployAtTransactionData.getTimestamp(); ATData skeletonAtData = new ATData(atAddress, creatorPublicKey, creation, assetId); int height = this.repository.getBlockRepository().getBlockchainHeight() + 1; @@ -157,7 +157,7 @@ public class DeployAtTransaction extends Transaction { QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); try { - new MachineState(api, loggerFactory, this.deployATTransactionData.getCreationBytes()); + new MachineState(api, loggerFactory, this.deployAtTransactionData.getCreationBytes()); } catch (IllegalArgumentException e) { // Not valid return ValidationResult.INVALID_CREATION_BYTES; @@ -169,25 +169,25 @@ public class DeployAtTransaction extends Transaction { @Override public ValidationResult isProcessable() throws DataException { Account creator = getCreator(); - long assetId = this.deployATTransactionData.getAssetId(); + long assetId = this.deployAtTransactionData.getAssetId(); // Check creator has enough funds if (assetId == Asset.QORT) { // Simple case: amount and fee both in QORT - long minimumBalance = this.deployATTransactionData.getFee() + this.deployATTransactionData.getAmount(); + long minimumBalance = this.deployAtTransactionData.getFee() + this.deployAtTransactionData.getAmount(); if (creator.getConfirmedBalance(Asset.QORT) < minimumBalance) return ValidationResult.NO_BALANCE; } else { - if (creator.getConfirmedBalance(Asset.QORT) < this.deployATTransactionData.getFee()) + if (creator.getConfirmedBalance(Asset.QORT) < this.deployAtTransactionData.getFee()) return ValidationResult.NO_BALANCE; - if (creator.getConfirmedBalance(assetId) < this.deployATTransactionData.getAmount()) + if (creator.getConfirmedBalance(assetId) < this.deployAtTransactionData.getAmount()) return ValidationResult.NO_BALANCE; } // Check AT doesn't already exist - if (this.repository.getATRepository().exists(this.deployATTransactionData.getAtAddress())) + if (this.repository.getATRepository().exists(this.deployAtTransactionData.getAtAddress())) return ValidationResult.AT_ALREADY_EXISTS; return ValidationResult.OK; @@ -195,40 +195,40 @@ public class DeployAtTransaction extends Transaction { @Override public void process() throws DataException { - this.ensureATAddress(); + ensureATAddress(this.deployAtTransactionData); // Deploy AT, saving into repository - AT at = new AT(this.repository, this.deployATTransactionData); + AT at = new AT(this.repository, this.deployAtTransactionData); at.deploy(); - long assetId = this.deployATTransactionData.getAssetId(); + long assetId = this.deployAtTransactionData.getAssetId(); // Update creator's balance regarding initial payment to AT Account creator = getCreator(); - creator.modifyAssetBalance(assetId, - this.deployATTransactionData.getAmount()); + creator.modifyAssetBalance(assetId, - this.deployAtTransactionData.getAmount()); // Update AT's reference, which also creates AT account Account atAccount = this.getATAccount(); - atAccount.setLastReference(this.deployATTransactionData.getSignature()); + atAccount.setLastReference(this.deployAtTransactionData.getSignature()); // Update AT's balance - atAccount.setConfirmedBalance(assetId, this.deployATTransactionData.getAmount()); + atAccount.setConfirmedBalance(assetId, this.deployAtTransactionData.getAmount()); } @Override public void orphan() throws DataException { // Delete AT from repository - AT at = new AT(this.repository, this.deployATTransactionData); + AT at = new AT(this.repository, this.deployAtTransactionData); at.undeploy(); - long assetId = this.deployATTransactionData.getAssetId(); + long assetId = this.deployAtTransactionData.getAssetId(); // Update creator's balance regarding initial payment to AT Account creator = getCreator(); - creator.modifyAssetBalance(assetId, this.deployATTransactionData.getAmount()); + creator.modifyAssetBalance(assetId, this.deployAtTransactionData.getAmount()); // Delete AT's account (and hence its balance) - this.repository.getAccountRepository().delete(this.deployATTransactionData.getAtAddress()); + this.repository.getAccountRepository().delete(this.deployAtTransactionData.getAtAddress()); } } diff --git a/src/main/java/org/qortal/utils/BitTwiddling.java b/src/main/java/org/qortal/utils/BitTwiddling.java index f13300c5..eda5b4f6 100644 --- a/src/main/java/org/qortal/utils/BitTwiddling.java +++ b/src/main/java/org/qortal/utils/BitTwiddling.java @@ -26,9 +26,26 @@ public class BitTwiddling { return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) }; } + /** Convert int to big-endian byte array */ + public static byte[] toBEByteArray(int value) { + return new byte[] { (byte) (value >> 24), (byte) (value >> 16), (byte) (value >> 8), (byte) (value) }; + } + + /** Convert long to big-endian byte array */ + public static byte[] toBEByteArray(long value) { + return new byte[] { (byte) (value >> 56), (byte) (value >> 48), (byte) (value >> 40), (byte) (value >> 32), + (byte) (value >> 24), (byte) (value >> 16), (byte) (value >> 8), (byte) (value) }; + } + /** Convert little-endian bytes to int */ - public static int fromLEBytes(byte[] bytes, int offset) { + public static int intFromLEBytes(byte[] bytes, int offset) { return (bytes[offset] & 0xff) | (bytes[offset + 1] & 0xff) << 8 | (bytes[offset + 2] & 0xff) << 16 | (bytes[offset + 3] & 0xff) << 24; } + /** Convert big-endian bytes to long */ + public static long longFromBEBytes(byte[] bytes, int start) { + return (bytes[start] & 0xffL) << 56 | (bytes[start + 1] & 0xffL) << 48 | (bytes[start + 2] & 0xffL) << 40 | (bytes[start + 3] & 0xffL) << 32 + | (bytes[start + 4] & 0xffL) << 24 | (bytes[start + 5] & 0xffL) << 16 | (bytes[start + 6] & 0xffL) << 8 | (bytes[start + 7] & 0xffL); + } + } diff --git a/src/test/java/org/qortal/test/at/GetMessageLengthTests.java b/src/test/java/org/qortal/test/at/GetMessageLengthTests.java new file mode 100644 index 00000000..730b441f --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetMessageLengthTests.java @@ -0,0 +1,223 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Random; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.at.QortalFunctionCode; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +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.test.common.AccountUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.BitTwiddling; + +public class GetMessageLengthTests extends Common { + + private static final Random RANDOM = new Random(); + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] creationBytes = buildMessageLengthAT(); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Send messages with known length + checkMessageLength(repository, deployer, atAddress, 1); + checkMessageLength(repository, deployer, atAddress, 10); + checkMessageLength(repository, deployer, atAddress, 32); + checkMessageLength(repository, deployer, atAddress, 99); + + // Finally, send a payment instead and check returned length is -1 + AccountUtils.pay(repository, deployer, atAddress, 123L); + // Mint another block so AT can process payment + BlockUtils.mintBlock(repository); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + long extractedLength = BitTwiddling.longFromBEBytes(dataBytes, 0); + + assertEquals(-1L, extractedLength); + } + } + + private void checkMessageLength(Repository repository, PrivateKeyAccount sender, String atAddress, int messageLength) throws DataException { + byte[] testMessage = new byte[messageLength]; + RANDOM.nextBytes(testMessage); + + sendMessage(repository, sender, testMessage, atAddress); + // Mint another block so AT can process message + BlockUtils.mintBlock(repository); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + long extractedLength = BitTwiddling.longFromBEBytes(dataBytes, 0); + + assertEquals(messageLength, extractedLength); + } + + private byte[] buildMessageLengthAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Make result first for easier extraction + final int addrResult = addrCounter++; + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // Code labels + Integer labelCheckTx = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, OpCode.calcOffset(codeByteBuffer, labelCheckTx))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTx = codeByteBuffer.position(); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + // Save message length + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrResult)); + + // Stop and wait for next block (and hence more transactions) + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + +} diff --git a/src/test/java/org/qortal/test/at/GetNextTransactionTests.java b/src/test/java/org/qortal/test/at/GetNextTransactionTests.java new file mode 100644 index 00000000..eafc22fb --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetNextTransactionTests.java @@ -0,0 +1,268 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +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.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class GetNextTransactionTests extends Common { + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetNextTransaction() throws DataException { + byte[] data = new byte[] { 0x44 }; + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] creationBytes = buildGetNextTransactionAT(); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + byte[] rawNextTimestamp = new byte[32]; + Transaction transaction; + + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Send message to someone other than AT + sendMessage(repository, deployer, data, deployer.getAddress()); + BlockUtils.mintBlock(repository); + + // Confirm AT does not find message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Send message to AT + transaction = sendMessage(repository, deployer, data, atAddress); + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + BlockUtils.mintBlock(repository); + assertTimestamp(repository, atAddress, transaction); + + // Mint a few blocks, then send non-AT message, followed by AT message + for (int i = 0; i < 5; ++i) + BlockUtils.mintBlock(repository); + sendMessage(repository, deployer, data, deployer.getAddress()); + transaction = sendMessage(repository, deployer, data, atAddress); + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + BlockUtils.mintBlock(repository); + assertTimestamp(repository, atAddress, transaction); + } + } + + private byte[] buildGetNextTransactionAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +} diff --git a/src/test/java/org/qortal/test/at/GetPartialMessageTests.java b/src/test/java/org/qortal/test/at/GetPartialMessageTests.java new file mode 100644 index 00000000..4bc9d9ea --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetPartialMessageTests.java @@ -0,0 +1,221 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.at.QortalFunctionCode; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +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.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; + +public class GetPartialMessageTests extends Common { + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetPartialMessage() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] messageData = "The quick brown fox jumped over the lazy dog.".getBytes(); + int[] offsets = new int[] { 0, 7, 32, 44, messageData.length }; + + byte[] creationBytes = buildGetPartialMessageAT(offsets); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + sendMessage(repository, deployer, messageData, atAddress); + + for (int offset : offsets) { + // Mint another block so AT can process message + BlockUtils.mintBlock(repository); + + byte[] expectedData = new byte[32]; + int byteCount = Math.min(32, messageData.length - offset); + System.arraycopy(messageData, offset, expectedData, 0, byteCount); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + byte[] actualData = new byte[32]; + System.arraycopy(dataBytes, MachineState.VALUE_SIZE, actualData, 0, 32); + + assertArrayEquals(expectedData, actualData); + } + } + } + + private byte[] buildGetPartialMessageAT(int... offsets) { + // Labels for data segment addresses + int addrCounter = 0; + + final int addrCopyOfBIndex = addrCounter++; + + // 2nd position for easy extraction + final int addrCopyOfB = addrCounter; + addrCounter += 4; + + final int addrResult = addrCounter++; + final int addrLastTxTimestamp = addrCounter++; + final int addrOffset = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + dataByteBuffer.putLong(addrCopyOfB); + + // Code labels + Integer labelCheckTx = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, OpCode.calcOffset(codeByteBuffer, labelCheckTx))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTx = codeByteBuffer.position(); + + // Generate code per offset + for (int i = 0; i < offsets.length; ++i) { + if (i > 0) + // Wait for next block + codeByteBuffer.put(OpCode.SLP_IMD.compile()); + + // Set offset + codeByteBuffer.put(OpCode.SET_VAL.compile(addrOffset, offsets[i])); + + // Extract partial message + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOffset)); + + // Copy B to data segment + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCopyOfBIndex)); + } + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 19fd7340..fd187938 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -1,7 +1,6 @@ package org.qortal.test.btcacct; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import java.time.Instant; import java.time.LocalDateTime; @@ -10,14 +9,15 @@ import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.function.Function; -import org.bitcoinj.core.Base58; import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -43,14 +43,18 @@ import com.google.common.primitives.Bytes; public class AtTests extends Common { - public static final byte[] secret = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a - public static final int refundTimeout = 10; // blocks - public static final long initialPayout = 100000L; + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); + public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 + public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; + private static final Random RANDOM = new Random(); + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -58,9 +62,9 @@ public class AtTests extends Common { @Test public void testCompile() { - Account deployer = Common.getTestAccount(null, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(null); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -68,12 +72,14 @@ public class AtTests extends Common { public void testDeploy() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -85,10 +91,10 @@ public class AtTests extends Common { assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-deployment balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -103,10 +109,10 @@ public class AtTests extends Common { assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); } } @@ -115,26 +121,39 @@ public class AtTests extends Common { public void testOfferCancel() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - // Send creator's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = BTCACCT.buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); long messageFee = messageTransaction.getTransactionData().getFee(); - // Refund should happen 1st block after receiving recipient address + // AT should process 'cancel' message in next block BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); + + // Check balances long expectedMinimumBalance = deployersPostDeploymentBalance; long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; @@ -143,11 +162,10 @@ public class AtTests extends Common { assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - describeAt(repository, atAddress); - // Test orphaning BlockUtils.orphanLastBlock(repository); + // Check balances long expectedBalance = deployersPostDeploymentBalance - messageFee; actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -157,71 +175,144 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testInitialPayment() throws DataException { + public void testOfferCancelInvalidLength() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - // Initial payment should happen 1st block after receiving recipient address + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); - // Test orphaning - BlockUtils.orphanLastBlock(repository); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipient's pre-initial-payout balance incorrect", expectedBalance, actualBalance); + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); } } - // TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerBitcoinPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) @SuppressWarnings("unused") @Test public void testIncorrectTradeSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT BUT NOT FROM AT CREATOR - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, bystander, recipientAddressBytes, atAddress); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - // Initial payment should NOT happen BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode); } } @@ -230,34 +321,48 @@ public class AtTests extends Common { public void testAutomaticTradeRefund() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - // Initial payment should happen 1st block after receiving recipient address - BlockUtils.mintBlock(repository); + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long messageFee = messageTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee; + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); describeAt(repository, atAddress); - // Test orphaning - BlockUtils.orphanLastBlock(repository); - BlockUtils.orphanLastBlock(repository); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances long expectedBalance = deployersPostDeploymentBalance; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -267,46 +372,63 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretCorrectSender() throws DataException { + public void testCorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - // Initial payment should happen 1st block after receiving recipient address + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT - messageTransaction = sendMessage(repository, recipient, secret, atAddress); + // Send correct secrets to AT, from correct account + messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + // Orphan redeem BlockUtils.orphanLastBlock(repository); - expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee(); - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipent's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); // Check AT state ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -317,99 +439,206 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretIncorrectSender() throws DataException { + public void testCorrectSecretsIncorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - // Initial payment should happen 1st block after receiving recipient address + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - messageTransaction = sendMessage(repository, bystander, secret, atAddress); + // Send correct secrets to AT, but from wrong account + messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @SuppressWarnings("unused") @Test - public void testIncorrectSecretCorrectSender() throws DataException { + public void testIncorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); - // Initial payment should happen 1st block after receiving recipient address + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - byte[] wrongSecret = Crypto.digest(secret); - messageTransaction = sendMessage(repository, recipient, wrongSecret, atAddress); + // Send incorrect secrets to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee(); - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + describeAt(repository, atAddress); - assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Send incorrect secrets to AT, from correct account + messageData = BTCACCT.buildRedeemMessage(secretA, wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + BlockUtils.mintBlock(repository); describeAt(repository, atAddress); + // Check AT is NOT finished + atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in TRADE mode + tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } + @SuppressWarnings("unused") + @Test + public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secrets to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA, secretB); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + } + } + @SuppressWarnings("unused") @Test public void testDescribeDeployed() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); List executableAts = repository.getATRepository().getAllExecutableATs(); @@ -433,8 +662,12 @@ public class AtTests extends Common { } } - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + private int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -493,6 +726,7 @@ public class AtTests extends Common { private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout * 3 / 4 + 1; // close enough // AT should automatically refund deployer after 'refundTimeout' blocks for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) @@ -500,7 +734,7 @@ public class AtTests extends Common { // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - initialPayout; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -516,40 +750,43 @@ public class AtTests extends Common { int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); System.out.print(String.format("%s:\n" + + "\tmode: %s\n" + "\tcreator: %s,\n" + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" - + "\tHASH160 of secret: %s,\n" - + "\tinitial payout: %s QORT,\n" + + "\tis finished: %b,\n" + + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\ttrade timeout: %d minutes (from trade start),\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, + tradeData.mode.name(), tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), - HashCode.fromBytes(tradeData.secretHash).toString().substring(0, 40), - Amounts.prettyAmount(tradeData.initialPayout), - Amounts.prettyAmount(tradeData.redeemPayout), + atData.getIsFinished(), + HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), + Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), - tradeData.tradeRefundTimeout, currentBlockHeight)); - // Are we in 'offer' or 'trade' stage? - if (tradeData.tradeRefundHeight == null) { - // Offer - System.out.println(String.format("\tstatus: 'offer mode'")); - } else { - // Trade - System.out.println(String.format("\tstatus: 'trade mode',\n" - + "\ttrade timeout: block %d,\n" - + "\tBitcoin P2SH nLockTime: %d (%s),\n" - + "\ttrade recipient: %s", + if (tradeData.mode != BTCACCT.Mode.OFFERING && tradeData.mode != BTCACCT.Mode.CANCELLED) { + System.out.println(String.format("\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-B nLockTime: %d (%s),\n" + + "\ttrade partner: %s", tradeData.tradeRefundHeight, - tradeData.lockTime, epochMilliFormatter.apply(tradeData.lockTime * 1000L), - tradeData.qortalRecipient)); + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.lockTimeB, epochMilliFormatter.apply(tradeData.lockTimeB * 1000L), + tradeData.qortalPartnerAddress)); } } + private PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index d0530c47..a00e54f6 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -5,12 +5,13 @@ import static org.junit.Assert.*; import java.util.Arrays; import java.util.List; +import org.bitcoinj.core.Transaction; import org.bitcoinj.store.BlockStoreException; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.repository.DataException; import org.qortal.test.common.Common; @@ -55,11 +56,52 @@ public class BtcTests extends Common { List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); - byte[] expectedSecret = AtTests.secret; - byte[] secret = BTCACCT.findP2shSecret(p2shAddress, rawTransactions); + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); assertNotNull(secret); assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); } + @Test + public void testBuildSpend() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = btc.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + + // Check spent key caching doesn't affect outcome + + transaction = btc.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + } + + @Test + public void testGetWalletBalance() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = btc.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(BTC.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = btc.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(BTC.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java index 25a95d8b..6b6b16e1 100644 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java @@ -13,7 +13,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -98,12 +98,12 @@ public class BuildP2SH { System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString())); System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress)); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash))); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -115,7 +115,7 @@ public class BuildP2SH { // Fund P2SH System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)", - p2shAddress.toString(), BTC.FORMAT.format(bitcoinAmount), BTC.FORMAT.format(bitcoinFee))); + p2shAddress.toString(), BTC.format(bitcoinAmount), BTC.format(bitcoinFee))); System.out.println("Once this is done, responder should run Respond to check P2SH funding and create AT"); } catch (DataException e) { diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index 8313d573..935d83eb 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -15,7 +15,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -106,14 +106,14 @@ public class CheckP2SH { System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString())); System.out.println(String.format("Redeem Bitcoin address: %s", refundBitcoinAddress)); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash))); System.out.println(String.format("P2SH address: %s", p2shAddress)); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -135,12 +135,12 @@ public class CheckP2SH { System.out.println(String.format("Too soon (%s) to redeem based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.FORMAT.format(p2shBalance))); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -152,7 +152,7 @@ public class CheckP2SH { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't redeem spent/unfunded P2SH")); diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 98672164..ef5a0295 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,20 +34,20 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" + "\t80.4020 \\\n" + "\t0.00864200 \\\n" + + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" - + "\t0.0001 \\\n" + "\t123.456 \\\n" - + "\t10")); + + "\t10080")); System.exit(1); } public static void main(String[] args) { - if (args.length != 8) + if (args.length != 7) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); @@ -56,8 +56,8 @@ public class DeployAT { byte[] refundPrivateKey = null; long redeemAmount = 0; long expectedBitcoin = 0; + byte[] bitcoinPublicKeyHash = null; byte[] secretHash = null; - long initialPayout = 0; long fundingAmount = 0; int tradeTimeout = 0; @@ -75,21 +75,21 @@ public class DeployAT { if (expectedBitcoin <= 0) usage("Expected BTC amount must be positive"); + bitcoinPublicKeyHash = HashCode.fromString(args[argIndex++]).asBytes(); + if (bitcoinPublicKeyHash.length != 20) + usage("Bitcoin PKH must be 20 bytes"); + secretHash = HashCode.fromString(args[argIndex++]).asBytes(); if (secretHash.length != 20) usage("Hash of secret must be 20 bytes"); - initialPayout = Long.parseLong(args[argIndex++]); - if (initialPayout < 0) - usage("Initial QORT payout must be positive"); - fundingAmount = Long.parseLong(args[argIndex++]); if (fundingAmount <= redeemAmount) usage("AT funding amount must be greater than QORT redeem amount"); tradeTimeout = Integer.parseInt(args[argIndex++]); - if (tradeTimeout < 10 || tradeTimeout > 50000) - usage("AT trade timeout should be between 10 and 50,000 minutes"); + if (tradeTimeout < 60 || tradeTimeout > 50000) + usage("Trade timeout (minutes) must be between 60 and 50000"); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -114,7 +114,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java index a8c3cb12..992af2ee 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -12,8 +12,8 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.Test; import org.qortal.crosschain.ElectrumX; +import org.qortal.crosschain.ElectrumX.UnspentOutput; import org.qortal.utils.BitTwiddling; -import org.qortal.utils.Pair; import com.google.common.hash.HashCode; @@ -61,7 +61,7 @@ public class ElectrumXTests { // Timestamp(int) is at 4 + 32 + 32 = 68 bytes offset int offset = 4 + 32 + 32; - int timestamp = BitTwiddling.fromLEBytes(blockHeader, offset); + int timestamp = BitTwiddling.intFromLEBytes(blockHeader, offset); System.out.println(String.format("Block %d timestamp: %d", height + i, timestamp)); } } @@ -100,13 +100,13 @@ public class ElectrumXTests { Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List> unspentOutputs = electrumX.getUnspentOutputs(script); + List unspentOutputs = electrumX.getUnspentOutputs(script); assertNotNull(unspentOutputs); assertFalse(unspentOutputs.isEmpty()); - for (Pair unspentOutput : unspentOutputs) - System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.getA()).toString(), unspentOutput.getB())); + for (UnspentOutput unspentOutput : unspentOutputs) + System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.hash), unspentOutput.index)); } @Test diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 091f2234..761d4796 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -18,7 +18,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -107,7 +107,7 @@ public class Redeem { System.out.println("Confirm the following is correct based on the info you've given:"); System.out.println(String.format("Redeem PRIVATE key: %s", HashCode.fromBytes(redeemPrivateKey))); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); // New/derived info @@ -121,7 +121,7 @@ public class Redeem { System.out.println(String.format("P2SH address: %s", p2shAddress)); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -147,12 +147,12 @@ public class Redeem { } // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -164,7 +164,7 @@ public class Redeem { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't redeem spent/unfunded P2SH")); @@ -179,10 +179,10 @@ public class Redeem { for (TransactionOutput fundingOutput : fundingOutputs) System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex())); - Coin redeemAmount = p2shBalance.subtract(bitcoinFee); - System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(redeemAmount), BTC.FORMAT.format(bitcoinFee))); + Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); + System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(redeemAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret); + Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret, redeemAddress.getHash()); byte[] redeemBytes = redeemTransaction.bitcoinSerialize(); diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index a694ee14..c6fd88ed 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -18,7 +18,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -110,7 +110,7 @@ public class Refund { System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress)); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("P2SH address: %s", p2shAddress)); - System.out.println(String.format("Refund miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Refund miner's fee: %s", BTC.format(bitcoinFee))); // New/derived info @@ -120,7 +120,7 @@ public class Refund { Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH); System.out.println(String.format("Refund recipient (PKH): %s (%s)", refundAddress, HashCode.fromBytes(refundAddress.getHash()))); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -151,12 +151,12 @@ public class Refund { } // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -168,7 +168,7 @@ public class Refund { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't refund spent/unfunded P2SH")); @@ -183,10 +183,10 @@ public class Refund { for (TransactionOutput fundingOutput : fundingOutputs) System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex())); - Coin refundAmount = p2shBalance.subtract(bitcoinFee); - System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(refundAmount), BTC.FORMAT.format(bitcoinFee))); + Coin refundAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); + System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(refundAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); + Transaction redeemTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); byte[] redeemBytes = redeemTransaction.bitcoinSerialize(); diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 59722ae1..0e7ef020 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -20,15 +20,19 @@ public class AccountUtils { public static final int txGroupId = Group.NO_GROUP; public static final long fee = 1L * Amounts.MULTIPLIER; - public static void pay(Repository repository, String sender, String recipient, long amount) throws DataException { - PrivateKeyAccount sendingAccount = Common.getTestAccount(repository, sender); - PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, recipient); + public static void pay(Repository repository, String testSenderName, String testRecipientName, long amount) throws DataException { + PrivateKeyAccount sendingAccount = Common.getTestAccount(repository, testSenderName); + PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, testRecipientName); + pay(repository, sendingAccount, recipientAccount.getAddress(), amount); + } + + public static void pay(Repository repository, PrivateKeyAccount sendingAccount, String recipientAddress, long amount) throws DataException { byte[] reference = sendingAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, sendingAccount.getPublicKey(), fee, null); - TransactionData transactionData = new PaymentTransactionData(baseTransactionData, recipientAccount.getAddress(), amount); + TransactionData transactionData = new PaymentTransactionData(baseTransactionData, recipientAddress, amount); TransactionUtils.signAndMint(repository, transactionData, sendingAccount); }