From 94d18538d820adcfcd3b256cd6215baba7d35343 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 21 Apr 2020 09:31:09 +0100 Subject: [PATCH] More work on cross-chain trading, including API calls. Added API calls to aid Qortal-side of cross-chain trading. POST /crosschain/build - for building Qortal AT POST /crosschain/tradeoffer/recipient - for sending trade partner/recipient to AT POST /crosschain/tradeoffer/secret - for sending secret to AT DELETE /crosschain/tradeoffer - for cancelling AT More fixes regarding Blocks processing/orphaning ATs. More fixes regarding sending/receiving blocks containing AT data. AT-related fix to genesis block. Improved cross-chain trading AT code, removing offer-mode timeout and replacing that with allowing AT creator to cancel offer/end AT by sending AT the creator's own address as trade partner/recipient. After all, they're not going to trade with themselves. Added assertion to check BTCACCT.CODE_BYTES_HASH matches compiled code hash. Added cross-chain AT's 'mode' for easier diagnosis, either OFFER or TRADE. We can't use AT's signature to generate AT address because address is needed before DEPLOY_AT transaction is signed. So we use a hash of signature-less transaction bytes. Corresponding changes to tests. --- .../api/model/CrossChainBuildRequest.java | 37 ++ .../api/model/CrossChainCancelRequest.java | 19 + .../api/model/CrossChainSecretRequest.java | 22 + .../api/model/CrossChainTradeRequest.java | 21 + .../api/resource/CrossChainResource.java | 375 ++++++++++++++++++ src/main/java/org/qortal/block/Block.java | 8 +- .../java/org/qortal/block/GenesisBlock.java | 4 + .../java/org/qortal/crosschain/BTCACCT.java | 173 ++++---- .../data/crosschain/CrossChainTradeData.java | 10 +- .../org/qortal/transaction/AtTransaction.java | 2 +- .../transaction/DeployAtTransaction.java | 14 +- .../transform/block/BlockTransformer.java | 11 + .../java/org/qortal/test/btcacct/AtTests.java | 42 +- .../org/qortal/test/btcacct/BuildP2SH.java | 6 +- .../org/qortal/test/btcacct/CheckP2SH.java | 2 +- .../java/org/qortal/test/btcacct/Common.java | 9 + .../org/qortal/test/btcacct/DeployAT.java | 31 +- .../java/org/qortal/test/btcacct/Redeem.java | 2 +- .../java/org/qortal/test/btcacct/Refund.java | 2 +- 19 files changed, 663 insertions(+), 127 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/CrossChainBuildRequest.java create mode 100644 src/main/java/org/qortal/api/model/CrossChainCancelRequest.java create mode 100644 src/main/java/org/qortal/api/model/CrossChainSecretRequest.java create mode 100644 src/main/java/org/qortal/api/model/CrossChainTradeRequest.java create mode 100644 src/test/java/org/qortal/test/btcacct/Common.java diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java new file mode 100644 index 00000000..094b4aed --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -0,0 +1,37 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +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 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") + public BigDecimal initialQortAmount; + + @Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000") + public BigDecimal finalQortAmount; + + @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000") + public BigDecimal fundingQortAmount; + + @Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV") + public byte[] secretHash; + + @Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200") + public BigDecimal bitcoinAmount; + + @Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") + public Integer tradeTimeout; + + public CrossChainBuildRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java new file mode 100644 index 00000000..8eab7f91 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -0,0 +1,19 @@ +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 CrossChainCancelRequest { + + @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] creatorPublicKey; + + public String atAddress; + + public CrossChainCancelRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java new file mode 100644 index 00000000..64c7bc89 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -0,0 +1,22 @@ +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 CrossChainSecretRequest { + + @Schema(description = "AT's 'recipient' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] recipientPublicKey; + + public String atAddress; + + @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") + public byte[] secret; + + public CrossChainSecretRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java new file mode 100644 index 00000000..ab53b587 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -0,0 +1,21 @@ +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 CrossChainTradeRequest { + + @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] creatorPublicKey; + + public String atAddress; + + public String recipient; + + public CrossChainTradeRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 83dbb0f8..7e53c53e 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -5,23 +5,34 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; import javax.ws.rs.GET; +import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; import org.ciyam.at.MachineState; +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.model.CrossChainCancelRequest; +import org.qortal.api.model.CrossChainSecretRequest; +import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.api.model.CrossChainBuildRequest; import org.qortal.asset.Asset; import org.qortal.at.QortalAtLoggerFactory; import org.qortal.crosschain.BTCACCT; @@ -29,9 +40,26 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.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.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; +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") @@ -102,4 +130,351 @@ public class CrossChainResource { } } + @POST + @Path("/build") + @Operation( + summary = "Build cross-chain trading AT", + description = "Returns raw, unsigned DEPLOY_AT transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBuildRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.REPOSITORY_ISSUE}) + public String buildTrade(CrossChainBuildRequest tradeRequest) { + 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 != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.tradeTimeout == null) + tradeRequest.tradeTimeout = 7 * 24 * 60; // 7 days + else + if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.initialQortAmount == null || tradeRequest.initialQortAmount.signum() < 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.finalQortAmount == null || tradeRequest.finalQortAmount.signum() <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.fundingQortAmount == null || tradeRequest.fundingQortAmount.signum() <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + // funding amount must exceed initial + final + if (tradeRequest.fundingQortAmount.compareTo(tradeRequest.initialQortAmount.add(tradeRequest.finalQortAmount)) <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.bitcoinAmount == null || tradeRequest.bitcoinAmount.signum() <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + 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); + + long txTimestamp = NTP.getTime(); + byte[] lastReference = creatorAccount.getLastReference(); + if (lastReference == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE); + + BigDecimal fee = BigDecimal.ZERO; + String name = "QORT-BTC cross-chain trade"; + String description = String.format("Qortal-Bitcoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, tradeRequest.fundingQortAmount, Asset.QORT); + + Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + ValidationResult result = deployAtTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/tradeoffer/recipient") + @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.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainTradeRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.REPOSITORY_ISSUE + }) + public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.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)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, tradeRequest.atAddress); + + // Determine state of AT + ATStateData atStateData = repository.getATRepository().getLatestATState(tradeRequest.atAddress); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); + + CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); + BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + + if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Good to make MESSAGE + + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0); + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/tradeoffer/secret") + @Operation( + summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient", + description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" + + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainSecretRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.REPOSITORY_ISSUE + }) + public String sendSecret(CrossChainSecretRequest secretRequest) { + byte[] recipientPublicKey = secretRequest.recipientPublicKey; + + if (recipientPublicKey == null || recipientPublicKey.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 != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, secretRequest.atAddress); // null to skip creator check + + // Determine state of AT + ATStateData atStateData = repository.getATRepository().getLatestATState(secretRequest.atAddress); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); + + CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); + BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + + if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey); + String recipientAddress = recipientAccount.getAddress(); + + // MESSAGE must come from address that AT considers trade partner / 'recipient' + if (!crossChainTradeData.qortalRecipient.equals(recipientAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Good to make MESSAGE + + byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/tradeoffer") + @Operation( + summary = "Builds raw, unsigned 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.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainCancelRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.REPOSITORY_ISSUE + }) + public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress); + + // Determine state of AT + ATStateData atStateData = repository.getATRepository().getLatestATState(cancelRequest.atAddress); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); + + CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); + BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + + if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Good to make MESSAGE + + PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); + String creatorAddress = creatorAccount.getAddress(); + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0); + + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, 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); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + + private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { + 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); + + BigDecimal fee = BigDecimal.ZERO; + BigDecimal amount = BigDecimal.ZERO; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, 4, atAddress, Asset.QORT, amount, messageData, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + ValidationResult result = messageTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + try { + return MessageTransactionTransformer.toBytes(messageTransactionData); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } + } + } \ No newline at end of file diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 37ae1bf1..7478cfe6 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1108,9 +1108,6 @@ public class Block { * @throws DataException */ private ValidationResult areAtsValid() throws DataException { - if (this.blockData.getATCount() == 0) - return ValidationResult.OK; - // Locally generated AT states should be valid so no need to re-execute them if (this.ourAtStates == this.getATStates()) // Note object reference compare return ValidationResult.OK; @@ -1207,8 +1204,7 @@ public class Block { // AT Transactions do not affect block's transaction count - // We've added transactions, so recalculate transactions signature - calcTransactionsSignature(); + // AT Transactions do not affect block's transaction signature } /** Returns whether block's minter is actually allowed to mint this block. */ @@ -1414,7 +1410,7 @@ public class Block { protected void processAtFeesAndStates() throws DataException { ATRepository atRepository = this.repository.getATRepository(); - for (ATStateData atStateData : this.getATStates()) { + for (ATStateData atStateData : this.ourAtStates) { Account atAccount = new Account(this.repository, atStateData.getATAddress()); // Subtract AT-generated fees from AT accounts diff --git a/src/main/java/org/qortal/block/GenesisBlock.java b/src/main/java/org/qortal/block/GenesisBlock.java index 94cb64e7..c5fae118 100644 --- a/src/main/java/org/qortal/block/GenesisBlock.java +++ b/src/main/java/org/qortal/block/GenesisBlock.java @@ -342,6 +342,10 @@ public class GenesisBlock extends Block { for (Transaction transaction : this.getTransactions()) this.repository.getTransactionRepository().save(transaction.getTransactionData()); + // No ATs in genesis block + this.ourAtStates = Collections.emptyList(); + this.ourAtFees = BigDecimal.ZERO.setScale(8); + super.process(); } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index c89eb456..81a8c66d 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -26,6 +26,7 @@ import org.ciyam.at.MachineState; import org.ciyam.at.OpCode; import org.ciyam.at.Timestamp; import org.qortal.account.Account; +import org.qortal.crypto.Crypto; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.utils.Base58; import org.qortal.utils.BitTwiddling; @@ -33,10 +34,30 @@ 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 + */ + public class BTCACCT { - public static final Coin DEFAULT_BTC_FEE = Coin.valueOf(1000L); // 0.00001000 BTC - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("da7271e9aa697112ece632cf2b462fded74843944a704b9d5fd4ae5971f6686f").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes /* * OP_TUCK (to copy public key to before signature) @@ -62,23 +83,14 @@ public class BTCACCT { private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF /** - * Returns Bitcoin redeem script. + * Returns Bitcoin redeemScript used for cross-chain trading. *

- *

-	 * OP_TUCK OP_CHECKSIGVERIFY
-	 * OP_HASH160 OP_DUP push(0x14) <refunder pubkeyhash> OP_EQUAL
-	 * OP_IF
-	 * 	OP_DROP push(0x04 bytes) <refund locktime> OP_CHECKLOCKTIMEVERIFY
-	 * OP_ELSE
-	 * 	push(0x14) <redeemer pubkeyhash> OP_EQUALVERIFY
-	 * 	OP_HASH160 push(0x14 bytes) <hash of secret> OP_EQUAL
-	 * OP_ENDIF
-	 * 
+ * See comments in {@link BTCACCT} for more details. * - * @param refunderPubKeyHash - * @param senderPubKey - * @param recipientPubKey - * @param lockTime + * @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) { @@ -89,14 +101,13 @@ public class BTCACCT { /** * Builds a custom transaction to spend P2SH. * - * @param amount - * @param spendKey - * @param recipientPubKeyHash - * @param fundingOutput - * @param redeemScriptBytes - * @param lockTime - * @param scriptSigBuilder - * @return + * @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, TransactionOutput fundingOutput, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) { NetworkParameters params = BTC.getInstance().getNetworkParameters(); @@ -135,6 +146,16 @@ public class BTCACCT { 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, TransactionOutput fundingOutput, byte[] redeemScriptBytes, long lockTime) { Function refundSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... @@ -156,6 +177,16 @@ public class BTCACCT { return buildP2shTransaction(refundAmount, refundKey, fundingOutput, 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, TransactionOutput fundingOutput, byte[] redeemScriptBytes, byte[] secret) { Function redeemSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... @@ -180,27 +211,21 @@ public class BTCACCT { return buildP2shTransaction(redeemAmount, redeemKey, fundingOutput, redeemScriptBytes, null, redeemSigScriptBuilder); } - /* - * 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 + /** + * Returns Qortal AT creation bytes for cross-chain trading AT. + *

+ * tradeTimeout (minutes) is the time window for the recipient 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 bitcoinAmount how much BTC the AT creator is expecting to trade + * @return */ - public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int offerTimeout, int tradeTimeout, BigDecimal initialPayout, BigDecimal redeemPayout, BigDecimal bitcoinAmount) { + public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, BigDecimal initialPayout, BigDecimal redeemPayout, BigDecimal bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; @@ -214,7 +239,6 @@ public class BTCACCT { final int addrSecretHash = addrCounter; addrCounter += 4; - final int addrOfferTimeout = addrCounter++; final int addrTradeTimeout = addrCounter++; final int addrInitialPayoutAmount = addrCounter++; final int addrRedeemPayoutAmount = addrCounter++; @@ -238,7 +262,6 @@ public class BTCACCT { final int addrQortalRecipient3 = addrCounter++; final int addrQortalRecipient4 = addrCounter++; - final int addrOfferRefundTimestamp = addrCounter++; final int addrTradeRefundTimestamp = addrCounter++; final int addrLastTxTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; @@ -265,10 +288,6 @@ public class BTCACCT { assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0)); - // Open offer timeout in minutes - assert dataByteBuffer.position() == addrOfferTimeout * MachineState.VALUE_SIZE : "addrOfferTimeout incorrect"; - dataByteBuffer.putLong(offerTimeout); - // Trade timeout in minutes assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; dataByteBuffer.putLong(tradeTimeout); @@ -315,6 +334,7 @@ public class BTCACCT { Integer labelOfferTxLoop = null; Integer labelCheckOfferTx = null; + Integer labelTradeMode = null; Integer labelTradeTxLoop = null; Integer labelCheckTradeTx = null; @@ -330,20 +350,10 @@ public class BTCACCT { // 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)); - // Calculate offer timeout refund 'timestamp' by adding addrOfferTimeout minutes to above 'timestamp', then save into addrOfferRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrOfferRefundTimestamp, addrLastTxTimestamp, addrOfferTimeout)); - // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for offer timeout or message from AT owner containing trade partner details */ - - // Fetch current block 'timestamp' - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); - // If we're not past offer timeout refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrOfferRefundTimestamp, calcOffset(codeByteBuffer, labelOfferTxLoop))); - // We've past offer timeout refund 'timestamp' so go refund everything back to AT creator - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); + /* Loop, waiting for message from AT owner containing trade partner details, or AT owner's address to cancel offer */ /* Transaction processing loop */ labelOfferTxLoop = codeByteBuffer.position(); @@ -385,6 +395,17 @@ public class BTCACCT { 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)); + + /* Switch to 'trade mode' */ + labelTradeMode = codeByteBuffer.position(); + // 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)); @@ -478,6 +499,9 @@ public class BTCACCT { byte[] codeBytes = new byte[codeByteBuffer.limit()]; codeByteBuffer.get(codeBytes); + assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH) + : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes))); + final short ciyamAtVersion = 2; final short numCallStackPages = 0; final short numUserStackPages = 0; @@ -486,6 +510,12 @@ public class BTCACCT { return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); } + /** + * Populates passed CrossChainTradeData with useful info extracted from AT data segment. + * + * @param tradeData + * @param dataBytes + */ public static void populateTradeData(CrossChainTradeData tradeData, byte[] dataBytes) { ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); byte[] addressBytes = new byte[32]; @@ -497,9 +527,6 @@ public class BTCACCT { tradeData.secretHash = new byte[32]; dataByteBuffer.get(tradeData.secretHash); - // Offer timeout - tradeData.offerRefundTimeout = dataByteBuffer.getLong(); - // Trade timeout tradeData.tradeRefundTimeout = dataByteBuffer.getLong(); @@ -532,17 +559,19 @@ public class BTCACCT { // Qortal recipient (if any) dataByteBuffer.get(addressBytes); - if (addressBytes[0] != 0) - tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); - - // Open offer timeout (AT 'timestamp' converted to Qortal block height) - long offerRefundTimestamp = dataByteBuffer.getLong(); - tradeData.offerRefundHeight = new Timestamp(offerRefundTimestamp).blockHeight; // Trade offer timeout (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); - if (tradeRefundTimestamp != 0) + + if (tradeRefundTimestamp != 0) { + tradeData.mode = CrossChainTradeData.Mode.TRADE; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; + + if (addressBytes[0] != 0) + tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); + } else { + tradeData.mode = CrossChainTradeData.Mode.OFFER; + } } } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index d055c830..961b9519 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -11,6 +11,8 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeData { + public static enum Mode { OFFER, TRADE }; + // Properties @Schema(description = "AT's Qortal address") @@ -37,12 +39,6 @@ public class CrossChainTradeData { @Schema(description = "Trade partner's Qortal address (trade begins when this is set)") public String qortalRecipient; - @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") - public long offerRefundTimeout; - - @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (before trade begins)") - public int offerRefundHeight; - @Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)") public long tradeRefundTimeout; @@ -52,6 +48,8 @@ public class CrossChainTradeData { @Schema(description = "Amount, in BTC, that AT creator expects Bitcoin P2SH to pay out (excluding miner fees)") public BigDecimal expectedBitcoin; + public Mode mode; + // Constructors // Necessary for JAXB diff --git a/src/main/java/org/qortal/transaction/AtTransaction.java b/src/main/java/org/qortal/transaction/AtTransaction.java index f55dfaf8..3871c6e2 100644 --- a/src/main/java/org/qortal/transaction/AtTransaction.java +++ b/src/main/java/org/qortal/transaction/AtTransaction.java @@ -187,7 +187,7 @@ public class AtTransaction extends Transaction { // For QORT amounts only: if recipient has no reference yet, then this is their starting reference if (assetId == Asset.QORT && recipient.getLastReference() == null) // In Qora1 last reference was set to 64-bytes of zero - // In Qortal we use AT-Transction's signature, which makes more sense + // In Qortal we use AT-Transaction's signature, which makes more sense recipient.setLastReference(this.atTransactionData.getSignature()); } } diff --git a/src/main/java/org/qortal/transaction/DeployAtTransaction.java b/src/main/java/org/qortal/transaction/DeployAtTransaction.java index 75d51dc0..d9c52ecc 100644 --- a/src/main/java/org/qortal/transaction/DeployAtTransaction.java +++ b/src/main/java/org/qortal/transaction/DeployAtTransaction.java @@ -22,7 +22,9 @@ import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.transform.TransformationException; import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; import com.google.common.base.Utf8; @@ -92,11 +94,15 @@ public class DeployAtTransaction extends Transaction { if (this.deployATTransactionData.getAtAddress() != null) return; - // For new version, simply use transaction signature + // For new version, simply use transaction transformer if (this.getVersion() > 1) { - String atAddress = Crypto.toATAddress(this.deployATTransactionData.getSignature()); - this.deployATTransactionData.setAtAddress(atAddress); - return; + try { + String atAddress = Crypto.toATAddress(DeployAtTransactionTransformer.toBytesForSigningImpl(this.deployATTransactionData)); + this.deployATTransactionData.setAtAddress(atAddress); + return; + } catch (TransformationException e) { + throw new DataException("Unable to generate AT address"); + } } int blockHeight = this.getHeight(); diff --git a/src/main/java/org/qortal/transform/block/BlockTransformer.java b/src/main/java/org/qortal/transform/block/BlockTransformer.java index e588805d..0481dda3 100644 --- a/src/main/java/org/qortal/transform/block/BlockTransformer.java +++ b/src/main/java/org/qortal/transform/block/BlockTransformer.java @@ -162,6 +162,9 @@ public class BlockTransformer extends Transformer { } } + // Bump byteBuffer over AT states just read in slice + byteBuffer.position(byteBuffer.position() + atBytesLength); + // AT count to reflect the number of states we have atCount = atStates.size(); @@ -295,6 +298,10 @@ public class BlockTransformer extends Transformer { bytes.write(Ints.toByteArray(atBytesLength)); for (ATStateData atStateData : block.getATStates()) { + // Skip initial states generated by DEPLOY_AT transactions in the same block + if (atStateData.isInitial()) + continue; + bytes.write(Base58.decode(atStateData.getATAddress())); bytes.write(atStateData.getStateHash()); Serialization.serializeBigDecimal(bytes, atStateData.getFees()); @@ -319,6 +326,10 @@ public class BlockTransformer extends Transformer { bytes.write(Ints.toByteArray(blockData.getTransactionCount())); for (Transaction transaction : block.getTransactions()) { + // Don't serialize AT transactions! + if (transaction.getTransactionData().getType() == TransactionType.AT) + continue; + TransactionData transactionData = transaction.getTransactionData(); bytes.write(Ints.toByteArray(TransactionTransformer.getDataLength(transactionData))); bytes.write(TransactionTransformer.toBytes(transactionData)); diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 21f4166c..00ab6107 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -61,7 +61,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -113,7 +113,7 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testAutomaticOfferRefund() throws DataException { + public void testOfferCancel() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -128,15 +128,29 @@ public class AtTests extends Common { BigDecimal deployAtFee = deployAtTransaction.getTransactionData().getFee(); BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee); - checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + // Send creator's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + BigDecimal messageFee = messageTransaction.getTransactionData().getFee(); + + // Refund should happen 1st block after receiving recipient address + BlockUtils.mintBlock(repository); + + BigDecimal expectedMinimumBalance = deployersPostDeploymentBalance; + BigDecimal expectedMaximumBalance = deployersInitialBalance.subtract(deployAtFee).subtract(messageFee); + + BigDecimal actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance.toPlainString(), expectedMinimumBalance.toPlainString()), actualBalance.compareTo(expectedMinimumBalance) > 0); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance.toPlainString(), expectedMaximumBalance.toPlainString()), actualBalance.compareTo(expectedMaximumBalance) < 0); describeAt(repository, atAddress); // Test orphaning BlockUtils.orphanLastBlock(repository); - BigDecimal expectedBalance = deployersPostDeploymentBalance; - BigDecimal actualBalance = deployer.getBalance(Asset.QORT); + BigDecimal expectedBalance = deployersPostDeploymentBalance.subtract(messageFee); + actualBalance = deployer.getBalance(Asset.QORT); Common.assertEqualBigDecimals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); } @@ -237,7 +251,7 @@ public class AtTests extends Common { BigDecimal messageFee = messageTransaction.getTransactionData().getFee(); BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee).subtract(messageFee); - checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); describeAt(repository, atAddress); @@ -340,7 +354,7 @@ public class AtTests extends Common { describeAt(repository, atAddress); - checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -382,7 +396,7 @@ public class AtTests extends Common { describeAt(repository, atAddress); - checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -421,7 +435,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -475,7 +489,7 @@ public class AtTests extends Common { return messageTransaction; } - private void checkAtRefund(Repository repository, Account deployer, BigDecimal deployersInitialBalance, BigDecimal deployAtFee) throws DataException { + private void checkTradeRefund(Repository repository, Account deployer, BigDecimal deployersInitialBalance, BigDecimal deployAtFee) throws DataException { BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee); // AT should automatically refund deployer after 'refundTimeout' blocks @@ -520,7 +534,6 @@ public class AtTests extends Common { + "\tinitial payout: %s QORT,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\toffer timeout: %d minutes (from creation),\n" + "\ttrade timeout: %d minutes (from trade start),\n" + "\tcurrent block height: %d,\n", tradeData.qortalAddress, @@ -531,18 +544,17 @@ public class AtTests extends Common { tradeData.initialPayout.toPlainString(), tradeData.redeemPayout.toPlainString(), tradeData.expectedBitcoin.toPlainString(), - tradeData.offerRefundTimeout, tradeData.tradeRefundTimeout, currentBlockHeight)); // Are we in 'offer' or 'trade' stage? if (tradeData.tradeRefundHeight == null) { // Offer - System.out.println(String.format("\toffer timeout: block %d", - tradeData.offerRefundHeight)); + System.out.println(String.format("\tstatus: 'offer mode'")); } else { // Trade - System.out.println(String.format("\ttrade timeout: block %d,\n" + System.out.println(String.format("\tstatus: 'trade mode',\n" + + "\ttrade timeout: block %d,\n" + "\ttrade recipient: %s", tradeData.tradeRefundHeight, tradeData.qortalRecipient)); diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java index f03fb8b5..33f86526 100644 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java @@ -54,7 +54,7 @@ public class BuildP2SH { Address redeemBitcoinAddress = null; byte[] secretHash = null; int lockTime = 0; - Coin bitcoinFee = BTCACCT.DEFAULT_BTC_FEE; + Coin bitcoinFee = Common.DEFAULT_BTC_FEE; int argIndex = 0; try { @@ -74,8 +74,8 @@ public class BuildP2SH { lockTime = Integer.parseInt(args[argIndex++]); int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L); - if (refundTimeoutDelay < 600 || refundTimeoutDelay > 7 * 24 * 60 * 60) - usage("Locktime (seconds) should be at between 10 minutes and 1 week from now"); + if (refundTimeoutDelay < 600 || refundTimeoutDelay > 30 * 24 * 60 * 60) + usage("Locktime (seconds) should be at between 10 minutes and 1 month from now"); if (args.length > argIndex) bitcoinFee = Coin.parseCoin(args[argIndex++]); diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index e61a031e..7803e0e6 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -58,7 +58,7 @@ public class CheckP2SH { Address redeemBitcoinAddress = null; byte[] secretHash = null; int lockTime = 0; - Coin bitcoinFee = BTCACCT.DEFAULT_BTC_FEE; + Coin bitcoinFee = Common.DEFAULT_BTC_FEE; int argIndex = 0; try { diff --git a/src/test/java/org/qortal/test/btcacct/Common.java b/src/test/java/org/qortal/test/btcacct/Common.java new file mode 100644 index 00000000..320d1c1c --- /dev/null +++ b/src/test/java/org/qortal/test/btcacct/Common.java @@ -0,0 +1,9 @@ +package org.qortal.test.btcacct; + +import org.bitcoinj.core.Coin; + +public abstract class Common { + + public static final Coin DEFAULT_BTC_FEE = Coin.parseCoin("0.00001000"); + +} diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 87a64f7c..01061132 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,19 +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" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + "\t0.0001 \\\n" - + "\t123.456")); + + "\t123.456 \\\n" + + "\t10")); System.exit(1); } public static void main(String[] args) { - if (args.length < 5 || args.length > 7) + if (args.length != 8) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); @@ -58,6 +59,7 @@ public class DeployAT { byte[] secretHash = null; BigDecimal initialPayout = BigDecimal.ZERO.setScale(8); BigDecimal fundingAmount = null; + int tradeTimeout = 0; int argIndex = 0; try { @@ -77,15 +79,15 @@ public class DeployAT { if (secretHash.length != 20) usage("Hash of secret must be 20 bytes"); - if (args.length > argIndex) - initialPayout = new BigDecimal(args[argIndex++]).setScale(8); + initialPayout = new BigDecimal(args[argIndex++]).setScale(8); - if (args.length > argIndex) { - fundingAmount = new BigDecimal(args[argIndex++]).setScale(8); + fundingAmount = new BigDecimal(args[argIndex++]).setScale(8); + if (fundingAmount.compareTo(redeemAmount) <= 0) + usage("AT funding amount must be greater than QORT redeem amount"); - if (fundingAmount.compareTo(redeemAmount) <= 0) - 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"); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -105,17 +107,12 @@ public class DeployAT { System.out.println(String.format("QORT redeem amount: %s", redeemAmount.toPlainString())); - if (fundingAmount == null) - fundingAmount = redeemAmount.add(atFundingExtra); System.out.println(String.format("AT funding amount: %s", fundingAmount.toPlainString())); System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - final int offerTimeout = 2 * 60; // minutes - final int tradeTimeout = 60; // minutes - - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, offerTimeout, tradeTimeout, initialPayout, fundingAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); @@ -133,7 +130,7 @@ public class DeployAT { String tags = "QORT-BTC ACCT"; BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, refundAccount.getPublicKey(), fee, null); - TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, redeemAmount, Asset.QORT); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index ad403cd2..3249e070 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -64,7 +64,7 @@ public class Redeem { byte[] redeemPrivateKey = null; byte[] secret = null; int lockTime = 0; - Coin bitcoinFee = BTCACCT.DEFAULT_BTC_FEE; + Coin bitcoinFee = Common.DEFAULT_BTC_FEE; int argIndex = 0; try { diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index 14752d8d..ffeb0080 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -64,7 +64,7 @@ public class Refund { Address redeemBitcoinAddress = null; byte[] secretHash = null; int lockTime = 0; - Coin bitcoinFee = BTCACCT.DEFAULT_BTC_FEE; + Coin bitcoinFee = Common.DEFAULT_BTC_FEE; int argIndex = 0; try {