From 8baf42765ed8502046b684c387a2a508a5c30ad2 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 17 Apr 2020 16:56:29 +0100 Subject: [PATCH] Improved cross-chain AT and more API support for same. Reworked the cross-chain trading AT so it is now 2-stage: stage 1: 'offer' mode waiting for message from creator containing trade partner's address stage 2: 'trade' mode waiting for message from trade partner containing secret Adjusted unit tests to cover above. Changed QortalATAPI.putCreatorAddressIntoB from storing creator's public key to actually storing creator's address. Refactored BTCACCT.AtConstants to CrossChainTradeData. Now we also store hash of AT's code bytes in DB so we can look up ATs by what they do. Affects ATData class, ATRepository, etc. Added "Automated Transactions" and "Cross-Chain" API sections. New API call GET /at/byfunction/{codehash} for looking up ATs by what they do, based on hash of their code bytes. New API call GET /at/{ataddress} for fetching info for specific AT. New API call GET /at/{ataddress}/data for fetch an AT's data segment. Mostly for diagnosis of AT's current state. New API call POST /at for building a raw, unsigned DEPLOY_AT transaction. New API call GET /crosschain/tradeoffers for finding open BTC-QORT trading ATs. --- .../qortal/api/resource/ApiDefinition.java | 2 + .../org/qortal/api/resource/AtResource.java | 206 ++++++++++ .../api/resource/CrossChainResource.java | 105 +++++ src/main/java/org/qortal/at/AT.java | 9 +- src/main/java/org/qortal/at/QortalATAPI.java | 9 +- .../java/org/qortal/crosschain/BTCACCT.java | 358 ++++++++++++------ src/main/java/org/qortal/data/at/ATData.java | 27 +- .../data/crosschain/CrossChainTradeData.java | 61 +++ .../org/qortal/repository/ATRepository.java | 3 + .../repository/hsqldb/HSQLDBATRepository.java | 95 ++++- .../hsqldb/HSQLDBDatabaseUpdates.java | 5 + .../java/org/qortal/test/btcacct/AtTests.java | 239 +++++++++--- .../org/qortal/test/btcacct/DeployAT.java | 42 +- 13 files changed, 942 insertions(+), 219 deletions(-) create mode 100644 src/main/java/org/qortal/api/resource/AtResource.java create mode 100644 src/main/java/org/qortal/api/resource/CrossChainResource.java create mode 100644 src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java diff --git a/src/main/java/org/qortal/api/resource/ApiDefinition.java b/src/main/java/org/qortal/api/resource/ApiDefinition.java index 52b6ade0..c887d4bc 100644 --- a/src/main/java/org/qortal/api/resource/ApiDefinition.java +++ b/src/main/java/org/qortal/api/resource/ApiDefinition.java @@ -13,7 +13,9 @@ import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Admin"), @Tag(name = "Arbitrary"), @Tag(name = "Assets"), + @Tag(name = "Automated Transactions"), @Tag(name = "Blocks"), + @Tag(name = "Cross-Chain"), @Tag(name = "Groups"), @Tag(name = "Names"), @Tag(name = "Payments"), diff --git a/src/main/java/org/qortal/api/resource/AtResource.java b/src/main/java/org/qortal/api/resource/AtResource.java new file mode 100644 index 00000000..86b79da9 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/AtResource.java @@ -0,0 +1,206 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +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.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiException; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.utils.Base58; + +@Path("/at") +@Tag(name = "Automated Transactions") +public class AtResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/byfunction/{codehash}") + @Operation( + summary = "Find automated transactions with matching functionality (code hash)", + responses = { + @ApiResponse( + description = "automated transactions", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = ATData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE + }) + public List getByFunctionality( + @PathParam("codehash") + String codeHash58, + @Parameter(description = "whether to include ATs that can run, or not, or both (if omitted)") + @QueryParam("isExecutable") + Boolean isExecutable, + @Parameter( ref = "limit") @QueryParam("limit") Integer limit, + @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, + @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { + // Decode codeHash + byte[] codeHash; + try { + codeHash = Base58.decode(codeHash58); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA, e); + } + + // codeHash must be present and have correct length + if (codeHash == null || codeHash.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Impose a limit on 'limit' + if (limit != null && limit > 100) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/{ataddress}") + @Operation( + summary = "Fetch info associated with AT address", + responses = { + @ApiResponse( + description = "automated transaction", + content = @Content( + schema = @Schema(implementation = ATData.class) + ) + ) + } + ) + @ApiErrors({ + ApiError.REPOSITORY_ISSUE + }) + public ATData getByAddress(@PathParam("ataddress") String atAddress) { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getATRepository().fromATAddress(atAddress); + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/{ataddress}/data") + @Operation( + summary = "Fetch data segment associated with AT address", + responses = { + @ApiResponse( + description = "automated transaction", + content = @Content( + schema = @Schema(implementation = byte[].class) + ) + ) + } + ) + @ApiErrors({ + ApiError.REPOSITORY_ISSUE + }) + public byte[] getDataByAddress(@PathParam("ataddress") String atAddress) { + try (final Repository repository = RepositoryManager.getRepository()) { + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + return dataBytes; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Operation( + summary = "Build raw, unsigned, DEPLOY_AT transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = DeployAtTransactionData.class + ) + ) + ), + responses = { + @ApiResponse( + description = "raw, unsigned, DEPLOY_AT transaction encoded in Base58", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.NON_PRODUCTION, ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String createDeployAt(DeployAtTransactionData transactionData) { + if (Settings.getInstance().isApiRestricted()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.NON_PRODUCTION); + + try (final Repository repository = RepositoryManager.getRepository()) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + ValidationResult result = transaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = DeployAtTransactionTransformer.toBytes(transactionData); + 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); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java new file mode 100644 index 00000000..83dbb0f8 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -0,0 +1,105 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.ArrayList; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; + +import org.ciyam.at.MachineState; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiException; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.crosschain.BTCACCT; +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.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; + +@Path("/crosschain") +@Tag(name = "Cross-Chain") +public class CrossChainResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/tradeoffers") + @Operation( + summary = "Find cross-chain trade offers", + responses = { + @ApiResponse( + description = "automated transactions", + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ + ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE + }) + public List getTradeOffers( + @Parameter( ref = "limit") @QueryParam("limit") Integer limit, + @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, + @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { + // Impose a limit on 'limit' + if (limit != null && limit > 100) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] codeHash = BTCACCT.CODE_BYTES_HASH; + boolean isExecutable = true; + + try (final Repository repository = RepositoryManager.getRepository()) { + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); + + List crossChainTradesData = new ArrayList<>(); + for (ATData atData : atsData) { + String atAddress = atData.getATAddress(); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, atStateData.getStateData()); + + CrossChainTradeData crossChainTradeData = new CrossChainTradeData(); + crossChainTradeData.qortalAddress = atAddress; + crossChainTradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); + crossChainTradeData.creationTimestamp = atData.getCreation(); + crossChainTradeData.qortBalance = repository.getAccountRepository().getBalance(atAddress, Asset.QORT).getBalance(); + + BTCACCT.populateTradeData(crossChainTradeData, dataBytes); + + crossChainTradesData.add(crossChainTradeData); + } + + return crossChainTradesData; + } catch (ApiException e) { + throw e; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/at/AT.java b/src/main/java/org/qortal/at/AT.java index 1ba5d8e2..d9c0adcd 100644 --- a/src/main/java/org/qortal/at/AT.java +++ b/src/main/java/org/qortal/at/AT.java @@ -58,7 +58,9 @@ public class AT { MachineState machineState = new MachineState(api, loggerFactory, deployATTransactionData.getCreationBytes()); - this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), + byte[] codeHash = Crypto.digest(machineState.getCodeBytes()); + + this.atData = new ATData(atAddress, creatorPublicKey, creation, machineState.version, assetId, machineState.getCodeBytes(), codeHash, machineState.isSleeping(), machineState.getSleepUntilHeight(), machineState.isFinished(), machineState.hadFatalError(), machineState.isFrozen(), machineState.getFrozenBalance()); @@ -104,9 +106,10 @@ public class AT { boolean hadFatalError = false; boolean isFrozen = false; Long frozenBalance = null; + byte[] codeHash = Crypto.digest(codeBytes); - this.atData = new ATData(atAddress, creatorPublicKey, creation, version, Asset.QORT, codeBytes, isSleeping, sleepUntilHeight, isFinished, - hadFatalError, isFrozen, frozenBalance); + this.atData = new ATData(atAddress, creatorPublicKey, creation, version, Asset.QORT, codeBytes, codeHash, + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); this.atStateData = new ATStateData(atAddress, height, creation, null, null, BigDecimal.ZERO.setScale(8), true); } diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 9455e59e..ea3f67dd 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -348,10 +348,15 @@ public class QortalATAPI extends API { @Override public void putCreatorAddressIntoB(MachineState state) { - // Simply use raw public key byte[] publicKey = atData.getCreatorPublicKey(); + String address = Crypto.toAddress(publicKey); - this.setB(state, publicKey); + // Convert to byte form as this only takes 25 bytes, + // compared to string-form's 34 bytes, + // and we only have 32 bytes available. + byte[] addressBytes = Bytes.ensureCapacity(Base58.decode(address), 32, 0); // pad to 32 bytes + + this.setB(state, addressBytes); } @Override diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 2789fb3d..c89eb456 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -24,7 +24,9 @@ import org.ciyam.at.CompilationException; import org.ciyam.at.FunctionCode; import org.ciyam.at.MachineState; import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; import org.qortal.account.Account; +import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.utils.Base58; import org.qortal.utils.BitTwiddling; @@ -34,23 +36,7 @@ import com.google.common.primitives.Bytes; 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("750012c7ae79d85a97e64e94c467c7791dd76cf3050b864f3166635a21d767c6").asBytes(); // SHA256 of AT code bytes - - public static class AtConstants { - public final byte[] secretHash; - public final BigDecimal initialPayout; - public final BigDecimal redeemPayout; - public final String recipient; - public final int refundMinutes; - - public AtConstants(byte[] secretHash, BigDecimal initialPayout, BigDecimal redeemPayout, String recipient, int refundMinutes) { - this.secretHash = secretHash; - this.initialPayout = initialPayout; - this.redeemPayout = redeemPayout; - this.recipient = recipient; - this.refundMinutes = refundMinutes; - } - } + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("da7271e9aa697112ece632cf2b462fded74843944a704b9d5fd4ae5971f6686f").asBytes(); // SHA256 of AT code bytes /* * OP_TUCK (to copy public key to before signature) @@ -194,59 +180,98 @@ public class BTCACCT { return buildP2shTransaction(redeemAmount, redeemKey, fundingOutput, redeemScriptBytes, null, redeemSigScriptBuilder); } - @SuppressWarnings("unused") - public static byte[] buildQortalAT(byte[] secretHash, String recipientQortalAddress, int refundMinutes, BigDecimal initialPayout, BigDecimal redeemPayout) { + /* + * 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 static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int offerTimeout, int tradeTimeout, BigDecimal initialPayout, BigDecimal redeemPayout, BigDecimal bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; + // Constants (with corresponding dataByteBuffer.put*() calls below) - final int addrHashPart1 = addrCounter++; - final int addrHashPart2 = addrCounter++; - final int addrHashPart3 = addrCounter++; - final int addrHashPart4 = addrCounter++; - final int addrAddressPart1 = addrCounter++; - final int addrAddressPart2 = addrCounter++; - final int addrAddressPart3 = addrCounter++; - final int addrAddressPart4 = addrCounter++; - final int addrRefundMinutes = addrCounter++; + + final int addrQortalCreator1 = addrCounter++; + final int addrQortalCreator2 = addrCounter++; + final int addrQortalCreator3 = addrCounter++; + final int addrQortalCreator4 = addrCounter++; + + final int addrSecretHash = addrCounter; + addrCounter += 4; + + final int addrOfferTimeout = addrCounter++; + final int addrTradeTimeout = addrCounter++; final int addrInitialPayoutAmount = addrCounter++; final int addrRedeemPayoutAmount = addrCounter++; - final int addrExpectedTxType = addrCounter++; - final int addrHashIndex = addrCounter++; - final int addrAddressIndex = addrCounter++; - final int addrAddressTempIndex = addrCounter++; - final int addrHashTempIndex = addrCounter++; - final int addrHashTempLength = addrCounter++; + final int addrBitcoinAmount = addrCounter++; + + final int addrMessageTxType = addrCounter++; + + final int addrSecretHashPointer = addrCounter++; + final int addrQortalRecipientPointer = addrCounter++; + final int addrMessageSenderPointer = addrCounter++; + + final int addrMessageDataPointer = addrCounter++; + final int addrMessageDataLength = addrCounter++; + final int addrEndOfConstants = addrCounter; + // Variables - final int addrRefundTimestamp = addrCounter++; - final int addrLastTimestamp = addrCounter++; + + final int addrQortalRecipient1 = addrCounter++; + final int addrQortalRecipient2 = addrCounter++; + final int addrQortalRecipient3 = addrCounter++; + final int addrQortalRecipient4 = addrCounter++; + + final int addrOfferRefundTimestamp = addrCounter++; + final int addrTradeRefundTimestamp = addrCounter++; + final int addrLastTxTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; final int addrTxType = addrCounter++; - final int addrComparator = addrCounter++; - final int addrAddressTemp1 = addrCounter++; - final int addrAddressTemp2 = addrCounter++; - final int addrAddressTemp3 = addrCounter++; - final int addrAddressTemp4 = addrCounter++; - final int addrHashTemp1 = addrCounter++; - final int addrHashTemp2 = addrCounter++; - final int addrHashTemp3 = addrCounter++; - final int addrHashTemp4 = addrCounter++; + final int addrResult = addrCounter++; + + final int addrMessageSender1 = addrCounter++; + final int addrMessageSender2 = addrCounter++; + final int addrMessageSender3 = addrCounter++; + final int addrMessageSender4 = addrCounter++; + + final int addrMessageData = addrCounter; + addrCounter += 4; // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - // Hash of secret into HashPart1-4 - assert dataByteBuffer.position() == addrHashPart1 * MachineState.VALUE_SIZE : "addrHashPart1 incorrect"; + // AT creator's Qortal address, decoded from Base58 + assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect"; + byte[] qortalCreatorBytes = Base58.decode(qortalCreator); + dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0)); + + // Hash of secret + assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0)); - // Recipient Qortal address, decoded from Base58 - assert dataByteBuffer.position() == addrAddressPart1 * MachineState.VALUE_SIZE : "addrAddressPart1 incorrect"; - byte[] recipientAddressBytes = Base58.decode(recipientQortalAddress); - dataByteBuffer.put(Bytes.ensureCapacity(recipientAddressBytes, 32, 0)); + // Open offer timeout in minutes + assert dataByteBuffer.position() == addrOfferTimeout * MachineState.VALUE_SIZE : "addrOfferTimeout incorrect"; + dataByteBuffer.putLong(offerTimeout); - // Expiry in minutes - assert dataByteBuffer.position() == addrRefundMinutes * MachineState.VALUE_SIZE : "addrRefundMinutes incorrect"; - dataByteBuffer.putLong(refundMinutes); + // Trade timeout in minutes + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); // Initial payout amount assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect"; @@ -256,34 +281,42 @@ public class BTCACCT { assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect"; dataByteBuffer.putLong(redeemPayout.unscaledValue().longValue()); + // Expected Bitcoin amount + assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect"; + dataByteBuffer.putLong(bitcoinAmount.unscaledValue().longValue()); + // We're only interested in MESSAGE transactions - assert dataByteBuffer.position() == addrExpectedTxType * MachineState.VALUE_SIZE : "addrExpectedTxType incorrect"; + assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect"; dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); // Index into data segment of hash, used by GET_B_IND - assert dataByteBuffer.position() == addrHashIndex * MachineState.VALUE_SIZE : "addrHashIndex incorrect"; - dataByteBuffer.putLong(addrHashPart1); + assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect"; + dataByteBuffer.putLong(addrSecretHash); // Index into data segment of recipient address, used by SET_B_IND - assert dataByteBuffer.position() == addrAddressIndex * MachineState.VALUE_SIZE : "addrAddressIndex incorrect"; - dataByteBuffer.putLong(addrAddressPart1); + assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect"; + dataByteBuffer.putLong(addrQortalRecipient1); // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND - assert dataByteBuffer.position() == addrAddressTempIndex * MachineState.VALUE_SIZE : "addrAddressTempIndex incorrect"; - dataByteBuffer.putLong(addrAddressTemp1); + assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; + dataByteBuffer.putLong(addrMessageSender1); // Source location and length for hashing any passed secret - assert dataByteBuffer.position() == addrHashTempIndex * MachineState.VALUE_SIZE : "addrHashTempIndex incorrect"; - dataByteBuffer.putLong(addrHashTemp1); - assert dataByteBuffer.position() == addrHashTempLength * MachineState.VALUE_SIZE : "addrHashTempLength incorrect"; + assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; + dataByteBuffer.putLong(addrMessageData); + assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; dataByteBuffer.putLong(32L); assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; // Code labels - Integer labelTxLoop = null; Integer labelRefund = null; - Integer labelCheckTx = null; + + Integer labelOfferTxLoop = null; + Integer labelCheckOfferTx = null; + + Integer labelTradeTxLoop = null; + Integer labelCheckTradeTx = null; ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); @@ -295,83 +328,136 @@ public class BTCACCT { /* Initialization */ // Use AT creation 'timestamp' as starting point for finding transactions sent to AT - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTimestamp)); - // Calculate refund 'timestamp' by adding minutes to above 'timestamp', then save into addrRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTimestamp, addrRefundMinutes)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); - // Load recipient's address into B register - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrAddressIndex)); - // 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)); + // 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()); - /* Main loop */ + /* 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 refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelTxLoop))); - // We're past refund 'timestamp' so go refund everything back to AT creator + // 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)); /* Transaction processing loop */ - labelTxLoop = codeByteBuffer.position(); + labelOfferTxLoop = codeByteBuffer.position(); // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrComparator)); - // If addrComparator is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrComparator, calcOffset(codeByteBuffer, labelCheckTx))); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckOfferTx))); // Stop and wait for next block codeByteBuffer.put(OpCode.STP_IMD.compile()); /* Check transaction */ - labelCheckTx = codeByteBuffer.position(); + labelCheckOfferTx = codeByteBuffer.position(); // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); // Extract transaction type (message/payment) from transaction and save type in addrTxType codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrExpectedTxType, calcOffset(codeByteBuffer, labelTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop))); /* Check transaction's sender */ // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrAddressTemp1 (as pointed to by addrAddressTempIndex) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrAddressTempIndex)); + // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrAddressTemp1, addrAddressPart1, calcOffset(codeByteBuffer, labelTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrAddressTemp2, addrAddressPart2, calcOffset(codeByteBuffer, labelTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrAddressTemp3, addrAddressPart3, calcOffset(codeByteBuffer, labelTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrAddressTemp4, addrAddressPart4, calcOffset(codeByteBuffer, labelTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop))); + + /* Extract trade partner info from message */ + + // Extract message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer)); + // 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)); + + // Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */ + + // Fetch current block 'timestamp' + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); + // If we're not past refund 'timestamp' then look for next transaction + codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop))); + // We're past refund 'timestamp' so go refund everything back to AT creator + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); + + /* Transaction processing loop */ + labelTradeTxLoop = codeByteBuffer.position(); + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTx))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTradeTx = codeByteBuffer.position(); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop))); + + /* Check transaction's sender */ + + // Extract sender address from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); + // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop))); /* Check 'secret' in transaction's message */ // Extract message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrHashTemp1 (as pointed to by addrHashTempIndex) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashTempIndex)); - // Load B register with expected hash result (as pointed to by addrHashIndex) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashIndex)); - // Perform HASH160 using source data at addrHashTemp1 through addrHashTemp4. (Location and length specified via addrHashTempIndex and addrHashTemplength). - // Save the equality result (1 if they match, 0 otherwise) into addrComparator. - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrComparator, addrHashTempIndex, addrHashTempLength)); - // If hashes don't match, addrComparator will be zero so go find another transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrComparator, calcOffset(codeByteBuffer, labelTxLoop))); + // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); + // Load B register with expected hash result (as pointed to by addrSecretHashPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer)); + // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). + // Save the equality result (1 if they match, 0 otherwise) into addrResult. + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); + // If hashes don't match, addrResult will be zero so go find another transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop))); /* Success! Pay arranged amount to intended recipient */ - // Load B register with intended recipient address (as pointed to by addrAddressIndex) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrAddressIndex)); + // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); // Pay AT's balance to recipient codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount)); - // We're finished forever - codeByteBuffer.put(OpCode.FIN_IMD.compile()); + // Fall-through to refunding any remaining balance back to AT creator /* Refund balance back to AT creator */ labelRefund = codeByteBuffer.position(); @@ -400,23 +486,63 @@ public class BTCACCT { return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); } - public static AtConstants extractAtConstants(byte[] dataBytes) { + public static void populateTradeData(CrossChainTradeData tradeData, byte[] dataBytes) { ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); - - byte[] secretHash = new byte[32]; - dataByteBuffer.get(secretHash); - byte[] addressBytes = new byte[32]; + + // Skip AT creator address + dataByteBuffer.position(dataByteBuffer.position() + 32); + + // Hash of secret + tradeData.secretHash = new byte[32]; + dataByteBuffer.get(tradeData.secretHash); + + // Offer timeout + tradeData.offerRefundTimeout = dataByteBuffer.getLong(); + + // Trade timeout + tradeData.tradeRefundTimeout = dataByteBuffer.getLong(); + + // Initial payout + tradeData.initialPayout = BigDecimal.valueOf(dataByteBuffer.getLong(), 8); + + // Redeem payout + tradeData.redeemPayout = BigDecimal.valueOf(dataByteBuffer.getLong(), 8); + + // Expected BTC amount + tradeData.expectedBitcoin = BigDecimal.valueOf(dataByteBuffer.getLong(), 8); + + // Skip MESSAGE transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to secretHash + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to Qortal recipient + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message sender + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message data + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip message data length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Qortal recipient (if any) dataByteBuffer.get(addressBytes); - String recipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); + if (addressBytes[0] != 0) + tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); - int refundMinutes = (int) dataByteBuffer.getLong(); + // Open offer timeout (AT 'timestamp' converted to Qortal block height) + long offerRefundTimestamp = dataByteBuffer.getLong(); + tradeData.offerRefundHeight = new Timestamp(offerRefundTimestamp).blockHeight; - BigDecimal initialPayout = BigDecimal.valueOf(dataByteBuffer.getLong(), 8); - - BigDecimal redeemPayout = BigDecimal.valueOf(dataByteBuffer.getLong(), 8); - - return new AtConstants(secretHash, initialPayout, redeemPayout, recipient, refundMinutes); + // Trade offer timeout (AT 'timestamp' converted to Qortal block height) + long tradeRefundTimestamp = dataByteBuffer.getLong(); + if (tradeRefundTimestamp != 0) + tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; } } diff --git a/src/main/java/org/qortal/data/at/ATData.java b/src/main/java/org/qortal/data/at/ATData.java index b12439e0..2e2a0c49 100644 --- a/src/main/java/org/qortal/data/at/ATData.java +++ b/src/main/java/org/qortal/data/at/ATData.java @@ -2,6 +2,11 @@ package org.qortal.data.at; import java.math.BigDecimal; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) public class ATData { // Properties @@ -11,6 +16,7 @@ public class ATData { private int version; private long assetId; private byte[] codeBytes; + private byte[] codeHash; private boolean isSleeping; private Integer sleepUntilHeight; private boolean isFinished; @@ -20,14 +26,19 @@ public class ATData { // Constructors - public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, boolean isSleeping, - Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, BigDecimal frozenBalance) { + // necessary for JAX-RS serialization + protected ATData() { + } + + public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash, + boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, BigDecimal frozenBalance) { this.ATAddress = ATAddress; this.creatorPublicKey = creatorPublicKey; this.creation = creation; this.version = version; this.assetId = assetId; this.codeBytes = codeBytes; + this.codeHash = codeHash; this.isSleeping = isSleeping; this.sleepUntilHeight = sleepUntilHeight; this.isFinished = isFinished; @@ -36,10 +47,10 @@ public class ATData { this.frozenBalance = frozenBalance; } - public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, boolean isSleeping, - Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) { - this(ATAddress, creatorPublicKey, creation, version, assetId, codeBytes, isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, - (BigDecimal) null); + public ATData(String ATAddress, byte[] creatorPublicKey, long creation, int version, long assetId, byte[] codeBytes, byte[] codeHash, + boolean isSleeping, Integer sleepUntilHeight, boolean isFinished, boolean hadFatalError, boolean isFrozen, Long frozenBalance) { + this(ATAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash, + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, (BigDecimal) null); // Convert Long frozenBalance to BigDecimal if (frozenBalance != null) @@ -80,6 +91,10 @@ public class ATData { return this.codeBytes; } + public byte[] getCodeHash() { + return this.codeHash; + } + public boolean getIsSleeping() { return this.isSleeping; } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java new file mode 100644 index 00000000..d055c830 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -0,0 +1,61 @@ +package org.qortal.data.crosschain; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeData { + + // Properties + + @Schema(description = "AT's Qortal address") + public String qortalAddress; + + @Schema(description = "AT creator's Qortal address") + public String qortalCreator; + + @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") + public long creationTimestamp; + + @Schema(description = "AT's current QORT balance") + public BigDecimal qortBalance; + + @Schema(description = "HASH160 of 32-byte secret") + public byte[] secretHash; + + @Schema(description = "Initial QORT payment that will be sent to Qortal trade partner") + public BigDecimal initialPayout; + + @Schema(description = "Final QORT payment that will be sent to Qortal trade partner") + public BigDecimal redeemPayout; + + @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; + + @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") + public Integer tradeRefundHeight; + + @Schema(description = "Amount, in BTC, that AT creator expects Bitcoin P2SH to pay out (excluding miner fees)") + public BigDecimal expectedBitcoin; + + // Constructors + + // Necessary for JAXB + public CrossChainTradeData() { + } + +} diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 9653fc6c..affbaf18 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -18,6 +18,9 @@ public interface ATRepository { /** Returns list of executable ATs, empty if none found */ public List getAllExecutableATs() throws DataException; + /** Returns list of ATs with matching code hash, optionally executable only. */ + public List getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException; + /** Returns creation block height given AT's address or null if not found */ public Integer getATCreationBlockHeight(String atAddress) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index dd85a241..e161437a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -26,7 +26,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public ATData fromATAddress(String atAddress) throws DataException { - String sql = "SELECT creator, creation, version, asset_id, code_bytes, " + String sql = "SELECT creator, creation, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, is_finished, had_fatal_error, " + "is_frozen, frozen_balance " + "FROM ATs " @@ -41,20 +41,21 @@ public class HSQLDBATRepository implements ATRepository { int version = resultSet.getInt(3); long assetId = resultSet.getLong(4); byte[] codeBytes = resultSet.getBytes(5); // Actually BLOB - boolean isSleeping = resultSet.getBoolean(6); + byte[] codeHash = resultSet.getBytes(6); + boolean isSleeping = resultSet.getBoolean(7); - Integer sleepUntilHeight = resultSet.getInt(7); + Integer sleepUntilHeight = resultSet.getInt(8); if (sleepUntilHeight == 0 && resultSet.wasNull()) sleepUntilHeight = null; - boolean isFinished = resultSet.getBoolean(8); - boolean hadFatalError = resultSet.getBoolean(9); - boolean isFrozen = resultSet.getBoolean(10); + boolean isFinished = resultSet.getBoolean(9); + boolean hadFatalError = resultSet.getBoolean(10); + boolean isFrozen = resultSet.getBoolean(11); - BigDecimal frozenBalance = resultSet.getBigDecimal(11); + BigDecimal frozenBalance = resultSet.getBigDecimal(12); - return new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, isSleeping, sleepUntilHeight, isFinished, hadFatalError, - isFrozen, frozenBalance); + return new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash, + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); } catch (SQLException e) { throw new DataException("Unable to fetch AT from repository", e); } @@ -71,7 +72,7 @@ public class HSQLDBATRepository implements ATRepository { @Override public List getAllExecutableATs() throws DataException { - String sql = "SELECT AT_address, creator, creation, version, asset_id, code_bytes, " + String sql = "SELECT AT_address, creator, creation, version, asset_id, code_bytes, code_hash, " + "is_sleeping, sleep_until_height, had_fatal_error, " + "is_frozen, frozen_balance " + "FROM ATs " @@ -93,19 +94,20 @@ public class HSQLDBATRepository implements ATRepository { int version = resultSet.getInt(4); long assetId = resultSet.getLong(5); byte[] codeBytes = resultSet.getBytes(6); // Actually BLOB - boolean isSleeping = resultSet.getBoolean(7); + byte[] codeHash = resultSet.getBytes(7); + boolean isSleeping = resultSet.getBoolean(8); - Integer sleepUntilHeight = resultSet.getInt(8); + Integer sleepUntilHeight = resultSet.getInt(9); if (sleepUntilHeight == 0 && resultSet.wasNull()) sleepUntilHeight = null; - boolean hadFatalError = resultSet.getBoolean(9); - boolean isFrozen = resultSet.getBoolean(10); + boolean hadFatalError = resultSet.getBoolean(10); + boolean isFrozen = resultSet.getBoolean(11); - BigDecimal frozenBalance = resultSet.getBigDecimal(11); + BigDecimal frozenBalance = resultSet.getBigDecimal(12); - ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, isSleeping, sleepUntilHeight, isFinished, - hadFatalError, isFrozen, frozenBalance); + ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash, + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); executableATs.add(atData); } while (resultSet.next()); @@ -116,6 +118,62 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException { + StringBuilder sql = new StringBuilder(512); + sql.append("SELECT AT_address, creator, creation, version, asset_id, code_bytes, ") + .append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ") + .append("is_frozen, frozen_balance ") + .append("FROM ATs ") + .append("WHERE code_hash = ? "); + + if (isExecutable != null) + sql.append("AND is_finished = ").append(isExecutable ? "false" : "true"); + + sql.append(" ORDER BY creation "); + if (reverse != null && reverse) + sql.append("DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List matchingATs = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), codeHash)) { + if (resultSet == null) + return matchingATs; + + do { + String atAddress = resultSet.getString(1); + byte[] creatorPublicKey = resultSet.getBytes(2); + long creation = getZonedTimestampMilli(resultSet, 3); + int version = resultSet.getInt(4); + long assetId = resultSet.getLong(5); + byte[] codeBytes = resultSet.getBytes(6); // Actually BLOB + boolean isSleeping = resultSet.getBoolean(7); + + Integer sleepUntilHeight = resultSet.getInt(8); + if (sleepUntilHeight == 0 && resultSet.wasNull()) + sleepUntilHeight = null; + + boolean isFinished = resultSet.getBoolean(9); + + boolean hadFatalError = resultSet.getBoolean(10); + boolean isFrozen = resultSet.getBoolean(11); + + BigDecimal frozenBalance = resultSet.getBigDecimal(12); + + ATData atData = new ATData(atAddress, creatorPublicKey, creation, version, assetId, codeBytes, codeHash, + isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance); + + matchingATs.add(atData); + } while (resultSet.next()); + + return matchingATs; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching ATs from repository", e); + } + } + @Override public Integer getATCreationBlockHeight(String atAddress) throws DataException { String sql = "SELECT height " @@ -140,7 +198,8 @@ public class HSQLDBATRepository implements ATRepository { HSQLDBSaver saveHelper = new HSQLDBSaver("ATs"); saveHelper.bind("AT_address", atData.getATAddress()).bind("creator", atData.getCreatorPublicKey()).bind("creation", toOffsetDateTime(atData.getCreation())) - .bind("version", atData.getVersion()).bind("asset_id", atData.getAssetId()).bind("code_bytes", atData.getCodeBytes()) + .bind("version", atData.getVersion()).bind("asset_id", atData.getAssetId()) + .bind("code_bytes", atData.getCodeBytes()).bind("code_hash", atData.getCodeHash()) .bind("is_sleeping", atData.getIsSleeping()).bind("sleep_until_height", atData.getSleepUntilHeight()) .bind("is_finished", atData.getIsFinished()).bind("had_fatal_error", atData.getHadFatalError()).bind("is_frozen", atData.getIsFrozen()) .bind("frozen_balance", atData.getFrozenBalance()); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 562f5030..c279c22a 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -980,6 +980,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE ATStates ADD COLUMN is_initial BOOLEAN NOT NULL DEFAULT TRUE"); break; + case 72: + // For ATs, add hash of code bytes to allow searching for specific function ATs, e.g. cross-chain trading + stmt.execute("ALTER TABLE ATs ADD COLUMN code_hash VARBINARY(32) NOT NULL BEFORE is_sleeping"); // Assuming something like SHA256 + break; + default: // nothing to do return false; diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 67968af6..21f4166c 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -10,7 +10,9 @@ import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; +import java.util.function.Function; +import org.bitcoinj.core.Base58; import org.ciyam.at.MachineState; import org.junit.Before; import org.junit.Test; @@ -22,6 +24,7 @@ import org.qortal.crosschain.BTCACCT; 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; @@ -37,6 +40,7 @@ import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; public class AtTests extends Common { @@ -46,6 +50,7 @@ public class AtTests extends Common { public static final BigDecimal initialPayout = new BigDecimal("0.001").setScale(8); public static final BigDecimal redeemAmount = new BigDecimal("80.4020").setScale(8); public static final BigDecimal fundingAmount = new BigDecimal("123.456").setScale(8); + public static final BigDecimal bitcoinAmount = new BigDecimal("0.00864200").setScale(8); @Before public void beforeTest() throws DataException { @@ -54,9 +59,9 @@ public class AtTests extends Common { @Test public void testCompile() { - String redeemAddress = Common.getTestAccount(null, "chloe").getAddress(); + Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(secretHash, redeemAddress, refundTimeout, initialPayout, redeemAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -69,7 +74,7 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); BigDecimal expectedBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtTransaction.getTransactionData().getFee()); BigDecimal actualBalance = deployer.getBalance(Asset.QORT); @@ -106,6 +111,37 @@ public class AtTests extends Common { } } + @SuppressWarnings("unused") + @Test + public void testAutomaticOfferRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + + BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); + BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + BigDecimal deployAtFee = deployAtTransaction.getTransactionData().getFee(); + BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee); + + checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + BigDecimal expectedBalance = deployersPostDeploymentBalance; + BigDecimal actualBalance = deployer.getBalance(Asset.QORT); + + Common.assertEqualBigDecimals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + @SuppressWarnings("unused") @Test public void testInitialPayment() throws DataException { @@ -116,9 +152,15 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); - // Initial payment should happen 1st block after deployment + // Send recipient's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + + // Initial payment should happen 1st block after receiving recipient address BlockUtils.mintBlock(repository); BigDecimal expectedBalance = recipientsInitialBalance.add(initialPayout); @@ -126,6 +168,8 @@ public class AtTests extends Common { Common.assertEqualBigDecimals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); + describeAt(repository, atAddress); + // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -136,9 +180,41 @@ public class AtTests extends Common { } } + // TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) @SuppressWarnings("unused") @Test - public void testAutomaticRefund() throws DataException { + public void testIncorrectTradeSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); + BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Send recipient's address to AT BUT NOT FROM AT CREATOR + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, bystander, recipientAddressBytes, atAddress); + + // Initial payment should NOT happen + BlockUtils.mintBlock(repository); + + BigDecimal expectedBalance = recipientsInitialBalance; + BigDecimal actualBalance = recipient.getConfirmedBalance(Asset.QORT); + + Common.assertEqualBigDecimals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -146,15 +222,28 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Send recipient's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + + // Initial payment should happen 1st block after receiving recipient address + BlockUtils.mintBlock(repository); BigDecimal deployAtFee = deployAtTransaction.getTransactionData().getFee(); - BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee); + BigDecimal messageFee = messageTransaction.getTransactionData().getFee(); + BigDecimal deployersPostDeploymentBalance = deployersInitialBalance.subtract(fundingAmount).subtract(deployAtFee).subtract(messageFee); checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); + describeAt(repository, atAddress); + // Test orphaning BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanLastBlock(repository); BigDecimal expectedBalance = deployersPostDeploymentBalance; BigDecimal actualBalance = deployer.getBalance(Asset.QORT); @@ -173,12 +262,19 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + // Send recipient's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + + // Initial payment should happen 1st block after receiving recipient address + BlockUtils.mintBlock(repository); + // Send correct secret to AT - MessageTransaction messageTransaction = sendSecret(repository, recipient, secret, atAddress); + messageTransaction = sendMessage(repository, recipient, secret, atAddress); // AT should send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -189,6 +285,8 @@ public class AtTests extends Common { Common.assertEqualBigDecimals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); + describeAt(repository, atAddress); + // Orphan redeem BlockUtils.orphanLastBlock(repository); @@ -215,14 +313,21 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); BigDecimal deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + // Send recipient's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + + // Initial payment should happen 1st block after receiving recipient address + BlockUtils.mintBlock(repository); + // Send correct secret to AT, but from wrong account - MessageTransaction messageTransaction = sendSecret(repository, bystander, secret, atAddress); + messageTransaction = sendMessage(repository, bystander, secret, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -233,6 +338,8 @@ public class AtTests extends Common { Common.assertEqualBigDecimals("Recipent's balance incorrect", expectedBalance, actualBalance); + describeAt(repository, atAddress); + checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -247,15 +354,22 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); BigDecimal deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + // Send recipient's address to AT + byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + + // Initial payment should happen 1st block after receiving recipient address + BlockUtils.mintBlock(repository); + // Send correct secret to AT, but from wrong account byte[] wrongSecret = Crypto.digest(secret); - MessageTransaction messageTransaction = sendSecret(repository, recipient, wrongSecret, atAddress); + messageTransaction = sendMessage(repository, recipient, wrongSecret, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -266,6 +380,8 @@ public class AtTests extends Common { Common.assertEqualBigDecimals("Recipent's balance incorrect", expectedBalance, actualBalance); + describeAt(repository, atAddress); + checkAtRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -280,10 +396,10 @@ public class AtTests extends Common { BigDecimal deployersInitialBalance = deployer.getBalance(Asset.QORT); BigDecimal recipientsInitialBalance = recipient.getBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, recipient); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); List executableAts = repository.getATRepository().getAllExecutableATs(); - + for (ATData atData : executableAts) { String atAddress = atData.getATAddress(); byte[] codeBytes = atData.getCodeBytes(); @@ -299,37 +415,13 @@ public class AtTests extends Common { if (!Arrays.equals(codeHash, BTCACCT.CODE_BYTES_HASH)) continue; - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - byte[] stateData = atStateData.getStateData(); - - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); - - BTCACCT.AtConstants atConstants = BTCACCT.extractAtConstants(dataBytes); - - long autoRefundTimestamp = atData.getCreation() + atConstants.refundMinutes * 60 * 1000L; - - String autoRefundString = LocalDateTime.ofInstant(Instant.ofEpochMilli(autoRefundTimestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); - System.out.println(String.format("%s:\n" - + "\tcreator: %s,\n" - + "\tHASH160 of secret: %s,\n" - + "\trecipient: %s,\n" - + "\tinitial payout: %s QORT,\n" - + "\tredeem payout: %s QORT,\n" - + "\tauto-refund at: %s (local time)", - atAddress, - Crypto.toAddress(atData.getCreatorPublicKey()), - HashCode.fromBytes(atConstants.secretHash).toString().substring(0, 40), - atConstants.recipient, - atConstants.initialPayout.toPlainString(), - atConstants.redeemPayout.toPlainString(), - autoRefundString)); + describeAt(repository, atAddress); } } } - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, Account recipient) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(secretHash, recipient.getAddress(), refundTimeout, initialPayout, redeemAmount); + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -341,7 +433,7 @@ public class AtTests extends Common { BigDecimal fee = BigDecimal.ZERO; String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade between %s and %s", deployer.getAddress(), recipient.getAddress()); + String description = String.format("Qortal-Bitcoin cross-chain trade"); String atType = "ACCT"; String tags = "QORT-BTC ACCT"; @@ -358,7 +450,7 @@ public class AtTests extends Common { return deployAtTransaction; } - private MessageTransaction sendSecret(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { long txTimestamp = System.currentTimeMillis(); byte[] lastReference = sender.getLastReference(); @@ -400,4 +492,61 @@ public class AtTests extends Common { assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance.toPlainString(), expectedMaximumBalance.toPlainString()), actualBalance.compareTo(expectedMaximumBalance) < 0); } + private void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + CrossChainTradeData tradeData = new CrossChainTradeData(); + tradeData.qortalAddress = atAddress; + tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); + tradeData.creationTimestamp = atData.getCreation(); + tradeData.qortBalance = repository.getAccountRepository().getBalance(atAddress, Asset.QORT).getBalance(); + + BTCACCT.populateTradeData(tradeData, dataBytes); + + Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); + int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); + + System.out.print(String.format("%s:\n" + + "\tcreator: %s,\n" + + "\tcreation timestamp: %s,\n" + + "\tcurrent balance: %s QORT,\n" + + "\tHASH160 of secret: %s,\n" + + "\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, + tradeData.qortalCreator, + epochMilliFormatter.apply(tradeData.creationTimestamp), + tradeData.qortBalance.toPlainString(), + HashCode.fromBytes(tradeData.secretHash).toString().substring(0, 40), + 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)); + } else { + // Trade + System.out.println(String.format("\ttrade timeout: block %d,\n" + + "\ttrade recipient: %s", + tradeData.tradeRefundHeight, + tradeData.qortalRecipient)); + } + } + } diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 488de43f..87a64f7c 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -2,16 +2,12 @@ package org.qortal.test.btcacct; import java.math.BigDecimal; import java.security.Security; -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.controller.Controller; import org.qortal.crosschain.BTCACCT; -import org.qortal.crypto.Crypto; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.TransactionData; @@ -38,14 +34,14 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT [ []]")); + System.err.println(String.format("usage: DeployAT [ []]")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" - + "\t3.1415 \\\n" - + "\tQgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v \\\n" + + "\t80.4020 \\\n" + + "\t0.00864200 \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" - + "\t1585920000 \\\n" - + "\t0.0001")); + + "\t0.0001 \\\n" + + "\t123.456")); System.exit(1); } @@ -58,9 +54,8 @@ public class DeployAT { byte[] refundPrivateKey = null; BigDecimal redeemAmount = null; - String redeemAddress = null; + BigDecimal expectedBitcoin = null; byte[] secretHash = null; - int lockTime = 0; BigDecimal initialPayout = BigDecimal.ZERO.setScale(8); BigDecimal fundingAmount = null; @@ -74,16 +69,14 @@ public class DeployAT { if (redeemAmount.signum() <= 0) usage("QORT amount must be positive"); - redeemAddress = args[argIndex++]; - if (!Crypto.isValidAddress(redeemAddress)) - usage("Redeem address invalid"); + expectedBitcoin = new BigDecimal(args[argIndex++]).setScale(8); + if (expectedBitcoin.signum() <= 0) + usage("Expected BTC amount must be positive"); secretHash = HashCode.fromString(args[argIndex++]).asBytes(); if (secretHash.length != 20) usage("Hash of secret must be 20 bytes"); - lockTime = Integer.parseInt(args[argIndex++]); - if (args.length > argIndex) initialPayout = new BigDecimal(args[argIndex++]).setScale(8); @@ -118,20 +111,11 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); - System.out.println(String.format("Redeem Qortal address: %s", redeemAddress)); - - // New/derived info - - System.out.println("\nCHECKING info from other party:"); - - System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneId.systemDefault()), lockTime)); - System.out.println("Make sure this is BEFORE P2SH lockTime to allow you to refund AT before P2SH refunded"); - // Deploy AT - final int BLOCK_TIME = 60; // seconds - final int refundTimeout = (lockTime - (int) (System.currentTimeMillis() / 1000L)) / BLOCK_TIME; + final int offerTimeout = 2 * 60; // minutes + final int tradeTimeout = 60; // minutes - byte[] creationBytes = BTCACCT.buildQortalAT(secretHash, redeemAddress, refundTimeout, initialPayout, fundingAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, offerTimeout, tradeTimeout, initialPayout, fundingAmount, expectedBitcoin); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); @@ -144,7 +128,7 @@ public class DeployAT { BigDecimal fee = BigDecimal.ZERO; String name = "QORT-BTC cross-chain trade"; - String description = String.format("Qortal-Bitcoin cross-chain trade between %s and %s", refundAccount.getAddress(), redeemAddress); + String description = String.format("Qortal-Bitcoin cross-chain trade"); String atType = "ACCT"; String tags = "QORT-BTC ACCT";