diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 847718a2..73f9b100 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -13,6 +13,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.function.Function; +import java.util.function.ToIntFunction; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.DELETE; @@ -392,9 +394,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh") + @Path("/p2sh/a") @Operation( - summary = "Returns Bitcoin P2SH address based on trade info", + summary = "Returns Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -411,7 +413,35 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b") + @Operation( + summary = "Returns Bitcoin P2SH-B address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -432,7 +462,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -443,9 +473,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/check") + @Path("/p2sh/a/check") @Operation( - summary = "Checks Bitcoin P2SH address based on trade info", + summary = "Checks Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -462,7 +492,35 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/check") + @Operation( + summary = "Checks Bitcoin P2SH-B address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -483,7 +541,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -508,7 +569,7 @@ public class CrossChainResource { if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { p2shStatus.canRedeem = now >= medianBlockTime * 1000L; - p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; + p2shStatus.canRefund = now >= lockTime * 1000L; } if (now >= medianBlockTime * 1000L) { @@ -524,9 +585,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/refund") + @Path("/p2sh/a/refund") @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address", + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address", requestBody = @RequestBody( required = true, content = @Content( @@ -544,7 +605,36 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) { + public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/refund") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-B address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRefundRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String refundP2sh(CrossChainBitcoinRefundRequest refundRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -580,7 +670,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -597,7 +690,7 @@ public class CrossChainResource { if (fundingOutputs.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - boolean canRefund = now >= crossChainTradeData.lockTime * 1000L; + boolean canRefund = now >= lockTime * 1000L; if (!canRefund) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); @@ -606,7 +699,7 @@ public class CrossChainResource { Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); + org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); if (!wasBroadcast) @@ -619,9 +712,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/redeem") + @Path("/p2sh/a/redeem") @Operation( - summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address", + summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address", requestBody = @RequestBody( required = true, content = @Content( @@ -639,7 +732,36 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) { + public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/redeem") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRedeemRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -678,7 +800,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 8c6e4ba9..0975bacb 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -299,15 +299,12 @@ public class QortalATAPI extends API { byte[] messageData = this.getMessageFromTransaction(transactionData); - // Check data length is appropriate, i.e. not larger than B - if (messageData.length > 4 * 8) - return; - // Pad messageData to fit B - byte[] paddedMessageData = Bytes.ensureCapacity(messageData, 4 * 8, 0); + if (messageData.length < 4 * 8) + messageData = Bytes.ensureCapacity(messageData, 4 * 8, 0); // Endian must be correct here so that (for example) a SHA256 message can be compared to one generated locally - this.setB(state, paddedMessageData); + this.setB(state, messageData); } @Override diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 7ea1fe22..373ceb81 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -34,7 +34,6 @@ import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; -import org.qortal.utils.Base58; import org.qortal.utils.NTP; public class TradeBot { @@ -123,7 +122,7 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); // P2SH_a to be funded - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.creatorBitcoinPKH, secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -189,7 +188,7 @@ public class TradeBot { return; } - long tradeStartTimestamp = atData.getCreation(); + long atCreationTimestamp = atData.getCreation(); String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); @@ -223,10 +222,10 @@ public class TradeBot { byte[] aliceForeignPublicKeyHash = new byte[20]; System.arraycopy(messageData, 20, aliceForeignPublicKeyHash, 0, 20); - // Determine P2SH address and confirm funded - // First P2SH refund timeout is last in chain, so add all of tradeTimeout - int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() * 60); - byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); + // Determine P2SH-A address and confirm funded + // First P2SH-A refund timeout is last in chain, so add all of tradeTimeout + int lockTimeA = BTCACCT.calcLockTimeA(atCreationTimestamp, tradeBotData.getTradeTimeout()); + byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); Long balance = BTC.getInstance().getBalance(p2shAddress); @@ -235,13 +234,10 @@ public class TradeBot { // Good to go - send MESSAGE to AT - byte[] aliceNativeAddress = Base58.decode(Crypto.toAddress(messageTransactionData.getCreatorPublicKey())); + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume - byte[] outgoingMessageData = new byte[96]; - System.arraycopy(aliceNativeAddress, 0, outgoingMessageData, 0, aliceNativeAddress.length); - System.arraycopy(aliceForeignPublicKeyHash, 0, outgoingMessageData, 32, 20); - System.arraycopy(aliceSecretHash, 0, outgoingMessageData, 64, 20); + byte[] outgoingMessageData = BTCACCT.buildOfferMessage(aliceNativeAddress, aliceForeignPublicKeyHash, aliceSecretHash); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 9ce84abf..e113c1aa 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -107,6 +107,8 @@ public class BTCACCT { * @return */ public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, int tradeTimeout, long qortAmount, long bitcoinAmount) { + int refundTimeout = calcRefundTimeout(tradeTimeout); + // Labels for data segment addresses int addrCounter = 0; @@ -207,7 +209,7 @@ public class BTCACCT { // Refund timeout in minutes (¾ of trade-timeout) assert dataByteBuffer.position() == addrRefundTimeout * MachineState.VALUE_SIZE : "addrRefundTimeout incorrect"; - dataByteBuffer.putLong(tradeTimeout * 3 / 4); + dataByteBuffer.putLong(refundTimeout); // Redeem Qort amount assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; @@ -263,7 +265,7 @@ public class BTCACCT { // Offset into TRADE MESSAGE data payload for extracting secret-B assert dataByteBuffer.position() == addrTradeMessageSecretBOffset * MachineState.VALUE_SIZE : "addrTradeMessageSecretBOffset incorrect"; - dataByteBuffer.putLong(64L); + dataByteBuffer.putLong(32L); // Source location and length for hashing any passed secret assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; @@ -634,9 +636,10 @@ public class BTCACCT { // Skip temporary message data dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - // Potential hash of secret A - byte[] hashOfSecretA = new byte[32]; + // Potential hash160 of secret A + byte[] hashOfSecretA = new byte[20]; dataByteBuffer.get(hashOfSecretA); + dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes // Potential recipient's Bitcoin PKH byte[] recipientBitcoinPKH = new byte[20]; @@ -651,6 +654,8 @@ public class BTCACCT { tradeData.qortalRecipient = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; tradeData.recipientBitcoinPKH = recipientBitcoinPKH; + tradeData.lockTimeA = calcLockTimeA(tradeData.creationTimestamp, tradeData.tradeTimeout); + tradeData.lockTimeB = calcLockTimeB(tradeData.creationTimestamp, tradeData.tradeTimeout); } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } @@ -658,4 +663,51 @@ public class BTCACCT { return tradeData; } + /** Returns trade-info MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildOfferMessage(String recipientQortalAddress, byte[] recipientBitcoinPKH, byte[] hashOfSecretA) { + byte[] data = new byte[32 + 32 + 32]; + byte[] recipientQortalAddressBytes = Base58.decode(recipientQortalAddress); + + System.arraycopy(recipientQortalAddressBytes, 0, data, 0, recipientQortalAddressBytes.length); + System.arraycopy(recipientBitcoinPKH, 0, data, 32, recipientBitcoinPKH.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + + return data; + } + + /** Returns refund MESSAGE payload for AT creator to cancel trade AT. */ + public static byte[] buildRefundMessage(String creatorQortalAddress) { + byte[] data = new byte[32]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); + + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); + + return data; + } + + /** Returns redeem MESSAGE payload for trade partner/recipient to send to AT. */ + public static byte[] buildTradeMessage(byte[] secretA, byte[] secretB) { + byte[] data = new byte[32 + 32]; + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(secretB, 0, data, 32, secretB.length); + + return data; + } + + /** Returns AT refundTimeout (minutes) based on tradeTimeout. */ + public static int calcRefundTimeout(int tradeTimeout) { + return tradeTimeout * 3 / 4; + } + + /** Returns P2SH-A lockTime (epoch seconds). */ + public static int calcLockTimeA(long atCreationTimestamp, int tradeTimeout) { + return (int) (atCreationTimestamp / 1000L + tradeTimeout * 60); + } + + /** Returns P2SH-B lockTime (epoch seconds). */ + public static int calcLockTimeB(long atCreationTimestamp, int tradeTimeout) { + return (int) (atCreationTimestamp / 1000L + tradeTimeout / 2 * 60); + } + } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index d5a1a7ff..11207dd5 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -64,8 +64,11 @@ public class CrossChainTradeData { public Mode mode; - @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout") - public Integer lockTime; + @Schema(description = "Suggested Bitcoin P2SH-A nLockTime based on trade timeout") + public Integer lockTimeA; + + @Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout") + public Integer lockTimeB; @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") public byte[] recipientBitcoinPKH; diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 3ae7de53..79a6edd1 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -1,7 +1,6 @@ package org.qortal.test.btcacct; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import java.time.Instant; import java.time.LocalDateTime; @@ -10,6 +9,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.function.Function; import org.bitcoinj.core.Base58; @@ -18,6 +18,7 @@ import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -43,10 +44,12 @@ import com.google.common.primitives.Bytes; public class AtTests extends Common { - public static final byte[] secret = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] bitcoinPublicKeyHash = new byte[20]; // not used in tests - public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a - public static final int refundTimeout = 10; // blocks + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); + public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 + public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 12; // blocks public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; @@ -60,7 +63,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -145,6 +148,10 @@ public class AtTests extends Common { describeAt(repository, atAddress); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -155,7 +162,59 @@ public class AtTests extends Common { } } - // TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long messageFee = messageTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(CrossChainTradeData.Mode.TRADE, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner/recipient Qortal address was extracted correctly + assertEquals(recipient.getAddress(), tradeData.qortalRecipient); + + // Check trade partner/recipient's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.recipientBitcoinPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) @SuppressWarnings("unused") @Test public void testIncorrectTradeSender() throws DataException { @@ -171,11 +230,10 @@ public class AtTests extends Common { 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); + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - // Initial payment should NOT happen BlockUtils.mintBlock(repository); long expectedBalance = recipientsInitialBalance; @@ -184,6 +242,12 @@ public class AtTests extends Common { assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(CrossChainTradeData.Mode.OFFER, tradeData.mode); } } @@ -201,12 +265,12 @@ public class AtTests extends Common { 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); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address - BlockUtils.mintBlock(repository); + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); long messageFee = messageTransaction.getTransactionData().getFee(); @@ -216,9 +280,12 @@ public class AtTests extends Common { describeAt(repository, atAddress); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + // Test orphaning - BlockUtils.orphanLastBlock(repository); - BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); long expectedBalance = deployersPostDeploymentBalance; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -229,7 +296,7 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretCorrectSender() throws DataException { + public void testCorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -241,27 +308,32 @@ public class AtTests extends Common { 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); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT - messageTransaction = sendMessage(repository, recipient, secret, atAddress); + // Send correct secrets to AT, from correct account + messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); - // Orphan redeem BlockUtils.orphanLastBlock(repository); @@ -279,7 +351,7 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretIncorrectSender() throws DataException { + public void testCorrectSecretsIncorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -294,34 +366,39 @@ public class AtTests extends Common { 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); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - messageTransaction = sendMessage(repository, bystander, secret, atAddress); + // Send correct secrets to AT, but from wrong account + messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @SuppressWarnings("unused") @Test - public void testIncorrectSecretCorrectSender() throws DataException { + public void testIncorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -335,28 +412,53 @@ public class AtTests extends Common { 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); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - byte[] wrongSecret = Crypto.digest(secret); - messageTransaction = sendMessage(repository, recipient, wrongSecret, atAddress); + // Send incorrect secrets to AT, from correct account + byte[] wrongSecret = new byte[32]; + Random random = new Random(); + random.nextBytes(wrongSecret); + messageData = BTCACCT.buildTradeMessage(wrongSecret, secretB); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + // Send incorrect secrets to AT, from correct account + messageData = BTCACCT.buildTradeMessage(secretA, wrongSecret); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); + + // AT should NOT send funds in the next block + BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + // Check AT is NOT finished + atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() * 2; + actualBalance = recipient.getConfirmedBalance(Asset.QORT); + + assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -396,7 +498,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -455,6 +557,7 @@ public class AtTests extends Common { private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = BTCACCT.calcRefundTimeout(tradeTimeout); // AT should automatically refund deployer after 'refundTimeout' blocks for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) @@ -481,15 +584,17 @@ public class AtTests extends Common { + "\tcreator: %s,\n" + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\ttrade timeout: %d minutes (from trade start),\n" + + "\ttrade timeout: %d minutes (from AT creation),\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), @@ -503,13 +608,15 @@ public class AtTests extends Common { } else { // Trade System.out.println(String.format("\tstatus: 'trade mode',\n" - + "\ttrade timeout: block %d,\n" + + "\trefund height: block %d,\n" + "\tHASH160 of secret-A: %s,\n" - + "\tBitcoin P2SH nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-B nLockTime: %d (%s),\n" + "\ttrade recipient: %s", tradeData.tradeRefundHeight, HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTime, epochMilliFormatter.apply(tradeData.lockTime * 1000L), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.lockTimeB, epochMilliFormatter.apply(tradeData.lockTimeB * 1000L), tradeData.qortalRecipient)); } } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index bd5211fc..b9f7869a 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -55,7 +55,7 @@ public class BtcTests extends Common { List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); - byte[] expectedSecret = AtTests.secret; + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); assertNotNull(secret);