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
+ * 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