forked from Qortal/qortal
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.
This commit is contained in:
parent
8baf42765e
commit
94d18538d8
@ -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() {
|
||||
}
|
||||
|
||||
}
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
@ -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.<br>"
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "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.<br>"
|
||||
+ "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "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.<br>"
|
||||
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
|
||||
+ "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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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.
|
||||
* <p>
|
||||
* <pre>
|
||||
* 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
|
||||
* </pre>
|
||||
* 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<byte[], Script> 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<byte[], Script> 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<byte[], Script> 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.
|
||||
* <p>
|
||||
* <tt>tradeTimeout</tt> (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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
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();
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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++]);
|
||||
|
@ -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 {
|
||||
|
9
src/test/java/org/qortal/test/btcacct/Common.java
Normal file
9
src/test/java/org/qortal/test/btcacct/Common.java
Normal file
@ -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");
|
||||
|
||||
}
|
@ -34,19 +34,20 @@ public class DeployAT {
|
||||
if (error != null)
|
||||
System.err.println(error);
|
||||
|
||||
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <BTC amount> <HASH160-of-secret> [<initial QORT payout> [<AT funding amount>]]"));
|
||||
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <BTC amount> <HASH160-of-secret> <initial QORT payout> <AT funding amount> <AT trade timeout>"));
|
||||
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);
|
||||
|
||||
if (args.length > argIndex) {
|
||||
fundingAmount = new BigDecimal(args[argIndex++]).setScale(8);
|
||||
|
||||
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);
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user