diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index b88edb5a..5baf2c5d 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -43,6 +43,9 @@ import org.qortal.api.websocket.ActiveChatsWebSocket; import org.qortal.api.websocket.AdminStatusWebSocket; import org.qortal.api.websocket.BlocksWebSocket; import org.qortal.api.websocket.ChatMessagesWebSocket; +import org.qortal.api.websocket.PresenceWebSocket; +import org.qortal.api.websocket.TradeBotWebSocket; +import org.qortal.api.websocket.TradeOffersWebSocket; import org.qortal.settings.Settings; public class ApiService { @@ -196,6 +199,9 @@ public class ApiService { context.addServlet(BlocksWebSocket.class, "/websockets/blocks"); context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*"); context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); + context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers"); + context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot"); + context.addServlet(PresenceWebSocket.class, "/websockets/presence"); // Start server this.server.start(); diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java new file mode 100644 index 00000000..074fd24d --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java @@ -0,0 +1,34 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinRedeemRequest { + + @Schema(description = "Bitcoin HASH160(public key) for refund", example = "2nGDBPPPFS1c9w1h33YwFk4KUJU2") + public byte[] refundPublicKeyHash; + + @Schema(description = "Bitcoin PRIVATE KEY for redeem", example = "cUvGNSnu14q6Hr1X7TESjYVTqBpFjj8GGLGjGdpJwD9NhSQKeYUk") + public byte[] redeemPrivateKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Bitcoin miner fee", example = "0.00001000") + public BigDecimal bitcoinMinerFee; + + @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") + public byte[] secret; + + @Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf") + public byte[] receivingAccountInfo; + + public CrossChainBitcoinRedeemRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java new file mode 100644 index 00000000..f2485389 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRefundRequest.java @@ -0,0 +1,31 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinRefundRequest { + + @Schema(description = "Bitcoin PRIVATE KEY for refund", example = "cSP3zTb6bfm8GATtAcEJ8LqYtNQmzZ9jE2wQUVnZGiBzojDdrwKV") + public byte[] refundPrivateKey; + + @Schema(description = "Bitcoin HASH160(public key) for redeem", example = "2daMveGc5pdjRyFacbxBzMksCbyC") + public byte[] redeemPublicKeyHash; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Bitcoin miner fee", example = "0.00001000") + public BigDecimal bitcoinMinerFee; + + @Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf") + public byte[] receivingAccountInfo; + + public CrossChainBitcoinRefundRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java new file mode 100644 index 00000000..b7510eaa --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinTemplateRequest.java @@ -0,0 +1,23 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinTemplateRequest { + + @Schema(description = "Bitcoin HASH160(public key) for refund", example = "2nGDBPPPFS1c9w1h33YwFk4KUJU2") + public byte[] refundPublicKeyHash; + + @Schema(description = "Bitcoin HASH160(public key) for redeem", example = "2daMveGc5pdjRyFacbxBzMksCbyC") + public byte[] redeemPublicKeyHash; + + @Schema(description = "Qortal AT address") + public String atAddress; + + public CrossChainBitcoinTemplateRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java new file mode 100644 index 00000000..2772eae1 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java @@ -0,0 +1,31 @@ +package org.qortal.api.model; + +import java.math.BigDecimal; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBitcoinyHTLCStatus { + + @Schema(description = "P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)") + public String bitcoinP2shAddress; + + @Schema(description = "P2SH balance") + public BigDecimal bitcoinP2shBalance; + + @Schema(description = "Can HTLC redeem yet?") + public boolean canRedeem; + + @Schema(description = "Can HTLC refund yet?") + public boolean canRefund; + + @Schema(description = "Secret used by HTLC redeemer") + public byte[] secret; + + public CrossChainBitcoinyHTLCStatus() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java new file mode 100644 index 00000000..e8d38703 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -0,0 +1,39 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainBuildRequest { + + @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] creatorPublicKey; + + @Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long qortAmount; + + @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long fundingQortAmount; + + @Schema(description = "HASH160 of creator's Bitcoin public key", example = "2daMveGc5pdjRyFacbxBzMksCbyC") + public byte[] bitcoinPublicKeyHash; + + @Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV") + public byte[] hashOfSecretB; + + @Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long bitcoinAmount; + + @Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") + public Integer tradeTimeout; + + public CrossChainBuildRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java new file mode 100644 index 00000000..25a18952 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -0,0 +1,20 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainCancelRequest { + + @Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] creatorPublicKey; + + @Schema(description = "Qortal trade AT address") + public String atAddress; + + public CrossChainCancelRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java new file mode 100644 index 00000000..b6705d5d --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainDualSecretRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainDualSecretRequest { + + @Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] partnerPublicKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") + public byte[] secretA; + + @Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx") + public byte[] secretB; + + @Schema(description = "Qortal address for receiving QORT from AT") + public String receivingAddress; + + public CrossChainDualSecretRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java new file mode 100644 index 00000000..bf71c2d2 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -0,0 +1,127 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.crosschain.AcctMode; +import org.qortal.data.crosschain.CrossChainTradeData; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainOfferSummary { + + // Properties + + @Schema(description = "AT's Qortal address") + private String qortalAtAddress; + + @Schema(description = "AT creator's Qortal address") + private String qortalCreator; + + @Schema(description = "AT creator's ephemeral trading key-pair represented as Qortal address") + private String qortalCreatorTradeAddress; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + @Schema(description = "Bitcoin amount - DEPRECATED: use foreignAmount") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + @Deprecated + private long btcAmount; + + @Schema(description = "Foreign blockchain amount") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long foreignAmount; + + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + private int tradeTimeout; + + @Schema(description = "Current AT execution mode") + private AcctMode mode; + + private long timestamp; + + @Schema(description = "Trade partner's Qortal receiving address") + private String partnerQortalReceivingAddress; + + private String foreignBlockchain; + + private String acctName; + + protected CrossChainOfferSummary() { + /* For JAXB */ + } + + public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) { + this.qortalAtAddress = crossChainTradeData.qortalAtAddress; + this.qortalCreator = crossChainTradeData.qortalCreator; + this.qortalCreatorTradeAddress = crossChainTradeData.qortalCreatorTradeAddress; + this.qortAmount = crossChainTradeData.qortAmount; + this.foreignAmount = crossChainTradeData.expectedForeignAmount; + this.btcAmount = this.foreignAmount; // Duplicate for deprecated field + this.tradeTimeout = crossChainTradeData.tradeTimeout; + this.mode = crossChainTradeData.mode; + this.timestamp = timestamp; + this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; + this.foreignBlockchain = crossChainTradeData.foreignBlockchain; + this.acctName = crossChainTradeData.acctName; + } + + public String getQortalAtAddress() { + return this.qortalAtAddress; + } + + public String getQortalCreator() { + return this.qortalCreator; + } + + public String getQortalCreatorTradeAddress() { + return this.qortalCreatorTradeAddress; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public long getBtcAmount() { + return this.btcAmount; + } + + public long getForeignAmount() { + return this.foreignAmount; + } + + public int getTradeTimeout() { + return this.tradeTimeout; + } + + public AcctMode getMode() { + return this.mode; + } + + public long getTimestamp() { + return this.timestamp; + } + + public String getPartnerQortalReceivingAddress() { + return this.partnerQortalReceivingAddress; + } + + public String getForeignBlockchain() { + return this.foreignBlockchain; + } + + public String getAcctName() { + return this.acctName; + } + + // For debugging mostly + + public String toString() { + return String.format("%s: %s", this.qortalAtAddress, this.mode); + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java new file mode 100644 index 00000000..2db475e5 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -0,0 +1,26 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainSecretRequest { + + @Schema(description = "Private key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] partnerPrivateKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Secret (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1") + public byte[] secret; + + @Schema(description = "Qortal address for receiving QORT from AT") + public String receivingAddress; + + public CrossChainSecretRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java new file mode 100644 index 00000000..1afd7290 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -0,0 +1,23 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeRequest { + + @Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] tradePublicKey; + + @Schema(description = "Qortal AT address") + public String atAddress; + + @Schema(description = "Signature of trading partner's 'offer' MESSAGE transaction") + public byte[] messageTransactionSignature; + + public CrossChainTradeRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java new file mode 100644 index 00000000..274dd818 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java @@ -0,0 +1,54 @@ +package org.qortal.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.data.crosschain.CrossChainTradeData; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class CrossChainTradeSummary { + + private long tradeTimestamp; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + @Deprecated + @Schema(description = "DEPRECATED: use foreignAmount instead") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long btcAmount; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long foreignAmount; + + protected CrossChainTradeSummary() { + /* For JAXB */ + } + + public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) { + this.tradeTimestamp = timestamp; + this.qortAmount = crossChainTradeData.qortAmount; + this.foreignAmount = crossChainTradeData.expectedForeignAmount; + this.btcAmount = this.foreignAmount; + } + + public long getTradeTimestamp() { + return this.tradeTimestamp; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public long getBtcAmount() { + return this.btcAmount; + } + + public long getForeignAmount() { + return this.foreignAmount; + } +} diff --git a/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java new file mode 100644 index 00000000..86d3d7c8 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class BitcoinSendRequest { + + @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE") + public String receivingAddress; + + @Schema(description = "Amount of BTC to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long bitcoinAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 BTC (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public BitcoinSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java new file mode 100644 index 00000000..5f215740 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class LitecoinSendRequest { + + @Schema(description = "Litecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Recipient's Litecoin address ('legacy' P2PKH only)", example = "LiTecoinEaterAddressDontSendhLfzKD") + public String receivingAddress; + + @Schema(description = "Amount of LTC to send", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long litecoinAmount; + + @Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 LTC (100 sats) per byte", example = "0.00000100", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long feePerByte; + + public LitecoinSendRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java new file mode 100644 index 00000000..1f96488e --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java @@ -0,0 +1,46 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.crosschain.SupportedBlockchain; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotCreateRequest { + + @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB") + public byte[] creatorPublicKey; + + @Schema(description = "QORT amount paid out on successful trade", example = "80.40000000", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long qortAmount; + + @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "80.50000000", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long fundingQortAmount; + + @Deprecated + @Schema(description = "Bitcoin amount wanted in return. DEPRECATED: use foreignAmount instead", example = "0.00864200", type = "number", hidden = true) + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long bitcoinAmount; + + @Schema(description = "Foreign blockchain. Note: default (BITCOIN) to be removed in the future", example = "BITCOIN", implementation = SupportedBlockchain.class) + public SupportedBlockchain foreignBlockchain; + + @Schema(description = "Foreign blockchain amount wanted in return", example = "0.00864200", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public Long foreignAmount; + + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + public int tradeTimeout; + + @Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") + public String receivingAddress; + + public TradeBotCreateRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java new file mode 100644 index 00000000..ecc8ed6f --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java @@ -0,0 +1,29 @@ +package org.qortal.api.model.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import io.swagger.v3.oas.annotations.media.Schema; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotRespondRequest { + + @Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + public String atAddress; + + @Deprecated + @Schema(description = "Bitcoin BIP32 extended private key. DEPRECATED: use foreignKey instead", hidden = true, + example = "xprv___________________________________________________________________________________________________________") + public String xprv58; + + @Schema(description = "Foreign blockchain private key, e.g. BIP32 'm' key for Bitcoin/Litecoin starting with 'xprv'", + example = "xprv___________________________________________________________________________________________________________") + public String foreignKey; + + @Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq") + public String receivingAddress; + + public TradeBotRespondRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/ApiDefinition.java b/src/main/java/org/qortal/api/resource/ApiDefinition.java index fa27bfbb..f9ec7459 100644 --- a/src/main/java/org/qortal/api/resource/ApiDefinition.java +++ b/src/main/java/org/qortal/api/resource/ApiDefinition.java @@ -22,6 +22,7 @@ import org.qortal.api.Security; @Tag(name = "Automated Transactions"), @Tag(name = "Blocks"), @Tag(name = "Chat"), + @Tag(name = "Cross-Chain"), @Tag(name = "Groups"), @Tag(name = "Names"), @Tag(name = "Payments"), @@ -40,4 +41,4 @@ import org.qortal.api.Security; @SecurityScheme(name = "apiKey", type = SecuritySchemeType.APIKEY, in = SecuritySchemeIn.HEADER, paramName = Security.API_KEY_HEADER) }) public class ApiDefinition { -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java new file mode 100644 index 00000000..20a27241 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java @@ -0,0 +1,363 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.Arrays; +import java.util.Random; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.CrossChainBuildRequest; +import org.qortal.api.model.CrossChainDualSecretRequest; +import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.asset.Asset; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +@Path("/crosschain/BitcoinACCTv1") +@Tag(name = "Cross-Chain (BitcoinACCTv1)") +public class CrossChainBitcoinACCTv1Resource { + + @Context + HttpServletRequest request; + + @POST + @Path("/build") + @Operation( + summary = "Build Bitcoin cross-chain trading AT", + description = "Returns raw, unsigned DEPLOY_AT transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBuildRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String buildTrade(CrossChainBuildRequest tradeRequest) { + Security.checkApiCallAllowed(request); + + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != Bitcoiny.HASH160_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.tradeTimeout == null) + tradeRequest.tradeTimeout = 7 * 24 * 60; // 7 days + else + if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.qortAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.fundingQortAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + // funding amount must exceed initial + final + if (tradeRequest.fundingQortAmount <= tradeRequest.qortAmount) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (tradeRequest.bitcoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + try (final Repository repository = RepositoryManager.getRepository()) { + PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); + + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, + tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout); + + long txTimestamp = NTP.getTime(); + byte[] lastReference = creatorAccount.getLastReference(); + if (lastReference == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE); + + long fee = 0; + String name = "QORT-BTC cross-chain trade"; + String description = "Qortal-Bitcoin cross-chain trade"; + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, tradeRequest.fundingQortAmount, Asset.QORT); + + Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + ValidationResult result = deployAtTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/trademessage") + @Operation( + summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", + description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainTradeRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { + Security.checkApiCallAllowed(request); + + byte[] tradePublicKey = tradeRequest.tradePublicKey; + + if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Does supplied public key match trade public key? + if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); + if (transactionData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN); + + if (transactionData.getType() != TransactionType.MESSAGE) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + byte[] messageData = messageTransactionData.getData(); + BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData); + if (offerMessageData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + // Good to make MESSAGE + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/redeemmessage") + @Operation( + summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", + description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainDualSecretRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public String buildRedeemMessage(CrossChainDualSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + + byte[] partnerPublicKey = secretRequest.partnerPublicKey; + + if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); + CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.TRADING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + String partnerAddress = Crypto.toAddress(partnerPublicKey); + + // MESSAGE must come from address that AT considers trade partner + if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Good to make MESSAGE + + byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress); + byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Must be correct AT - check functionality using code hash + if (!Arrays.equals(atData.getCodeHash(), BitcoinACCTv1.CODE_BYTES_HASH)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + + private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { + long txTimestamp = NTP.getTime(); + + // senderPublicKey could be ephemeral trade public key where there is no corresponding account and hence no reference + String senderAddress = Crypto.toAddress(senderPublicKey); + byte[] lastReference = repository.getAccountRepository().getLastReference(senderAddress); + final boolean requiresPoW = lastReference == null; + + if (requiresPoW) { + Random random = new Random(); + lastReference = new byte[Transformer.SIGNATURE_LENGTH]; + random.nextBytes(lastReference); + } + + int version = 4; + int nonce = 0; + long amount = 0L; + Long assetId = null; // no assetId as amount is zero + Long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + if (requiresPoW) { + messageTransaction.computeNonce(); + } else { + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + } + + ValidationResult result = messageTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + try { + return MessageTransactionTransformer.toBytes(messageTransactionData); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java new file mode 100644 index 00000000..2c1c6991 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -0,0 +1,167 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.BitcoinSendRequest; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SimpleTransaction; + +@Path("/crosschain/btc") +@Tag(name = "Cross-Chain (Bitcoin)") +public class CrossChainBitcoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns BTC balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getBitcoinWalletBalance(String key58) { + Security.checkApiCallAllowed(request); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = bitcoin.getWalletBalance(key58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + } + + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public List getBitcoinWalletTransactions(String key58) { + Security.checkApiCallAllowed(request); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return bitcoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/send") + @Operation( + summary = "Sends BTC from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Bitcoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = BitcoinSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (bitcoinSendRequest.bitcoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (bitcoinSendRequest.feePerByte != null && bitcoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidAddress(bitcoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!bitcoin.isValidDeterministicKey(bitcoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = bitcoin.buildSpend(bitcoinSendRequest.xprv58, + bitcoinSendRequest.receivingAddress, + bitcoinSendRequest.bitcoinAmount, + bitcoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + bitcoin.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java new file mode 100644 index 00000000..98e9b01d --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -0,0 +1,603 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.math.BigDecimal; +import java.util.List; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.*; +import org.bitcoinj.script.Script; +import org.qortal.api.*; +import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; +import org.qortal.crosschain.*; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +@Path("/crosschain/htlc") +@Tag(name = "Cross-Chain (Hash time-locked contracts)") +public class CrossChainHtlcResource { + + private static final Logger LOGGER = LogManager.getLogger(CrossChainHtlcResource.class); + + @Context + HttpServletRequest request; + + @GET + @Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") + @Operation( + summary = "Returns HTLC address based on trade info", + description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA}) + public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName, + @PathParam("refundPKH") String refundPKH, + @PathParam("locktime") int lockTime, + @PathParam("redeemPKH") String redeemPKH, + @PathParam("hashOfSecret") String hashOfSecret) { + SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); + if (blockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] refunderPubKeyHash; + byte[] redeemerPubKeyHash; + byte[] decodedHashOfSecret; + + try { + refunderPubKeyHash = Base58.decode(refundPKH); + redeemerPubKeyHash = Base58.decode(redeemPKH); + + if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } + + try { + decodedHashOfSecret = Base58.decode(hashOfSecret); + if (decodedHashOfSecret.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); + + Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); + + return bitcoiny.deriveP2shAddress(redeemScript); + } + + @GET + @Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}") + @Operation( + summary = "Checks HTLC status", + description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (base58 encoded). Locktime is seconds since epoch.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName, + @PathParam("refundPKH") String refundPKH, + @PathParam("locktime") int lockTime, + @PathParam("redeemPKH") String redeemPKH, + @PathParam("hashOfSecret") String hashOfSecret) { + Security.checkApiCallAllowed(request); + + SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName); + if (blockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] refunderPubKeyHash; + byte[] redeemerPubKeyHash; + byte[] decodedHashOfSecret; + + try { + refunderPubKeyHash = Base58.decode(refundPKH); + redeemerPubKeyHash = Base58.decode(redeemPKH); + + if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + } + + try { + decodedHashOfSecret = Base58.decode(hashOfSecret); + if (decodedHashOfSecret.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } catch (IllegalArgumentException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, decodedHashOfSecret); + + Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance(); + + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScript); + + long now = NTP.getTime(); + + try { + int medianBlockTime = bitcoiny.getMedianBlockTime(); + + // Check P2SH is funded + long p2shBalance = bitcoiny.getConfirmedBalance(p2shAddress.toString()); + + CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus(); + htlcStatus.bitcoinP2shAddress = p2shAddress; + htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); + + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString()); + + if (p2shBalance > 0L && !fundingOutputs.isEmpty()) { + htlcStatus.canRedeem = now >= medianBlockTime * 1000L; + htlcStatus.canRefund = now >= lockTime * 1000L; + } + + if (now >= medianBlockTime * 1000L) { + // See if we can extract secret + htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoiny, htlcStatus.bitcoinP2shAddress); + } + + return htlcStatus; + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @GET + @Path("/redeem/LITECOIN/{ataddress}/{tradePrivateKey}/{secret}/{receivingAddress}") + @Operation( + summary = "Redeems HTLC associated with supplied AT, using private key, secret, and receiving address", + description = "Secret and private key should be 32 bytes (base58 encoded). Receiving address must be a valid LTC P2PKH address.
" + + "The secret can be found in Alice's trade bot data or in the message to Bob's AT.
" + + "The trade private key and receiving address can be found in Bob's trade bot data.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean redeemHtlc(@PathParam("ataddress") String atAddress, + @PathParam("tradePrivateKey") String tradePrivateKey, + @PathParam("secret") String secret, + @PathParam("receivingAddress") String receivingAddress) { + Security.checkApiCallAllowed(request); + + // base58 decode the trade private key + byte[] decodedTradePrivateKey = null; + if (tradePrivateKey != null) + decodedTradePrivateKey = Base58.decode(tradePrivateKey); + + // base58 decode the secret + byte[] decodedSecret = null; + if (secret != null) + decodedSecret = Base58.decode(secret); + + // Convert supplied Litecoin receiving address into public key hash (we only support P2PKH at this time) + Address litecoinReceivingAddress; + try { + litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), receivingAddress); + } catch (AddressFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + if (litecoinReceivingAddress.getOutputScriptType() != Script.ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash(); + + return this.doRedeemHtlc(atAddress, decodedTradePrivateKey, decodedSecret, litecoinReceivingAccountInfo); + } + + @GET + @Path("/redeem/LITECOIN/{ataddress}") + @Operation( + summary = "Redeems HTLC associated with supplied AT", + description = "To be used by a QORT seller (Bob) who needs to redeem LTC proceeds that are stuck in a P2SH.
" + + "This requires Bob's trade bot data to be present in the database for this AT.
" + + "It will fail if the buyer has yet to redeem the QORT held in the AT.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean redeemHtlc(@PathParam("ataddress") String atAddress) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Attempt to find secret from the buyer's message to AT + byte[] decodedSecret = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + if (decodedSecret == null) { + LOGGER.info(() -> String.format("Unable to find secret-A from redeem message to AT %s", atAddress)); + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + } + + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + + // Search for the tradePrivateKey in the tradebot data + byte[] decodedPrivateKey = null; + if (tradeBotData != null) + decodedPrivateKey = tradeBotData.getTradePrivateKey(); + + // Search for the litecoin receiving address in the tradebot data + byte[] litecoinReceivingAccountInfo = null; + if (tradeBotData != null) + // Use receiving address PKH from tradebot data + litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + return this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/redeemAll/LITECOIN") + @Operation( + summary = "Redeems HTLC for all applicable ATs in tradebot data", + description = "To be used by a QORT seller (Bob) who needs to redeem LTC proceeds that are stuck in P2SH transactions.
" + + "This requires Bob's trade bot data to be present in the database for any ATs that need redeeming.
" + + "Returns true if at least one trade is redeemed. More detail is available in the log.txt.* file.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean redeemAllHtlc() { + Security.checkApiCallAllowed(request); + boolean success = false; + + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + + for (TradeBotData tradeBotData : allTradeBotData) { + String atAddress = tradeBotData.getAtAddress(); + if (atAddress == null) { + LOGGER.info("Missing AT address in tradebot data", atAddress); + continue; + } + + String tradeState = tradeBotData.getState(); + if (tradeState == null) { + LOGGER.info("Missing trade state for AT {}", atAddress); + continue; + } + + if (tradeState.startsWith("ALICE")) { + LOGGER.info("AT {} isn't redeemable because it is a buy order", atAddress); + continue; + } + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) { + LOGGER.info("Couldn't find AT with address {}", atAddress); + continue; + } + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) { + continue; + } + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) { + LOGGER.info("Couldn't find crosschain trade data for AT {}", atAddress); + continue; + } + + // Attempt to find secret from the buyer's message to AT + byte[] decodedSecret = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + if (decodedSecret == null) { + LOGGER.info("Unable to find secret-A from redeem message to AT {}", atAddress); + continue; + } + + // Search for the tradePrivateKey in the tradebot data + byte[] decodedPrivateKey = tradeBotData.getTradePrivateKey(); + + // Search for the litecoin receiving address PKH in the tradebot data + byte[] litecoinReceivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + try { + LOGGER.info("Attempting to redeem P2SH balance associated with AT {}...", atAddress); + boolean redeemed = this.doRedeemHtlc(atAddress, decodedPrivateKey, decodedSecret, litecoinReceivingAccountInfo); + if (redeemed) { + LOGGER.info("Redeemed P2SH balance associated with AT {}", atAddress); + success = true; + } + else { + LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Already redeemed?", atAddress); + } + } catch (ApiException e) { + LOGGER.info("Couldn't redeem P2SH balance associated with AT {}. Missing data?", atAddress); + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + + return success; + } + + private boolean doRedeemHtlc(String atAddress, byte[] decodedTradePrivateKey, byte[] decodedSecret, byte[] litecoinReceivingAccountInfo) { + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate trade private key + if (decodedTradePrivateKey == null || decodedTradePrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate secret + if (decodedSecret == null || decodedSecret.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Validate receiving address + if (litecoinReceivingAccountInfo == null || litecoinReceivingAccountInfo.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Make sure the receiving address isn't a QORT address, given that we can share the same field for both QORT and LTC + if (Crypto.isValidAddress(litecoinReceivingAccountInfo)) + if (Base58.encode(litecoinReceivingAccountInfo).startsWith("Q")) + // This is likely a QORT address, not an LTC + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + + // Use secret-A to redeem P2SH-A + + Litecoin litecoin = Litecoin.getInstance(); + + int lockTime = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTime, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + LOGGER.info(String.format("Redeeming P2SH address: %s", p2shAddressA)); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund + return false; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Double-check that we have redeemed P2SH-A... + return false; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // Wait for AT to auto-refund + return false; + + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey redeemKey = ECKey.fromPrivate(decodedTradePrivateKey); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, decodedSecret, litecoinReceivingAccountInfo); + + litecoin.broadcastTransaction(p2shRedeemTransaction); + return true; // TODO: validate? + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + + return false; + } + + @GET + @Path("/refund/LITECOIN/{ataddress}") + @Operation( + summary = "Refunds HTLC associated with supplied AT", + description = "To be used by a QORT buyer (Alice) who needs to refund their LTC that is stuck in a P2SH.
" + + "This requires Alice's trade bot data to be present in the database for this AT.
" + + "It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean refundHtlc(@PathParam("ataddress") String atAddress) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (tradeBotData.getForeignKey() == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Determine LTC receive address for refund + Litecoin litecoin = Litecoin.getInstance(); + String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + + return this.doRefundHtlc(atAddress, receiveAddress); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + } + + @GET + @Path("/refund/LITECOIN/{ataddress}/{receivingAddress}") + @Operation( + summary = "Refunds HTLC associated with supplied AT, to the specified LTC receiving address", + description = "To be used by a QORT buyer (Alice) who needs to refund their LTC that is stuck in a P2SH.
" + + "This requires Alice's trade bot data to be present in the database for this AT.
" + + "It will fail if it's already redeemed by the seller, or if the lockTime (60 minutes) hasn't passed yet.", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "boolean")) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN}) + public boolean refundHtlc(@PathParam("ataddress") String atAddress, + @PathParam("receivingAddress") String receivingAddress) { + Security.checkApiCallAllowed(request); + return this.doRefundHtlc(atAddress, receivingAddress); + } + + + private boolean doRefundHtlc(String atAddress, String receiveAddress) { + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + + int lockTime = tradeBotData.getLockTimeA(); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTime * 1000L) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + + Litecoin litecoin = Litecoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = litecoin.getMedianBlockTime(); + if (medianBlockTime <= lockTime) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + + case REDEEM_IN_PROGRESS: + case REDEEMED: + case REFUND_IN_PROGRESS: + case REFUNDED: + // Too late! + return false; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + // Validate the destination LTC address + Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress); + if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); + + litecoin.broadcastTransaction(p2shRefundTransaction); + return true; // TODO: validate? + } + } + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, e); + } + + return false; + } + + private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java new file mode 100644 index 00000000..04923133 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinACCTv1Resource.java @@ -0,0 +1,145 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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 org.qortal.account.PrivateKeyAccount; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.CrossChainSecretRequest; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import java.util.Arrays; +import java.util.Random; + +@Path("/crosschain/LitecoinACCTv1") +@Tag(name = "Cross-Chain (LitecoinACCTv1)") +public class CrossChainLitecoinACCTv1Resource { + + @Context + HttpServletRequest request; + + @POST + @Path("/redeemmessage") + @Operation( + summary = "Signs and broadcasts a 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", + description = "Specify address of cross-chain AT that needs to be messaged, Alice's trade private key, the 32-byte secret,
" + + "and an address for receiving QORT from AT. All of these can be found in Alice's trade bot data.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to use the private key that the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainSecretRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public boolean buildRedeemMessage(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + + byte[] partnerPrivateKey = secretRequest.partnerPrivateKey; + + if (partnerPrivateKey == null || partnerPrivateKey.length != Transformer.PRIVATE_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (secretRequest.secret == null || secretRequest.secret.length != LitecoinACCTv1.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); + CrossChainTradeData crossChainTradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.TRADING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] partnerPublicKey = new PrivateKeyAccount(null, partnerPrivateKey).getPublicKey(); + String partnerAddress = Crypto.toAddress(partnerPublicKey); + + // MESSAGE must come from address that AT considers trade partner + if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Good to make MESSAGE + + byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretRequest.secret, secretRequest.receivingAddress); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, partnerPrivateKey); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, secretRequest.atAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + return true; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // Must be correct AT - check functionality using code hash + if (!Arrays.equals(atData.getCodeHash(), LitecoinACCTv1.CODE_BYTES_HASH)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java new file mode 100644 index 00000000..8883f964 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -0,0 +1,167 @@ +package org.qortal.api.resource; + +import io.swagger.v3.oas.annotations.Operation; +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.POST; +import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.bitcoinj.core.Transaction; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.LitecoinSendRequest; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.SimpleTransaction; + +@Path("/crosschain/ltc") +@Tag(name = "Cross-Chain (Litecoin)") +public class CrossChainLitecoinResource { + + @Context + HttpServletRequest request; + + @POST + @Path("/walletbalance") + @Operation( + summary = "Returns LTC balance for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String getLitecoinWalletBalance(String key58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = litecoin.getWalletBalance(key58); + if (balance == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + return balance.toString(); + } + + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public List getLitecoinWalletTransactions(String key58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return litecoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + + @POST + @Path("/send") + @Operation( + summary = "Sends LTC from hierarchical, deterministic BIP32 wallet to specific address", + description = "Currently only supports 'legacy' P2PKH Litecoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = LitecoinSendRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public String sendBitcoin(LitecoinSendRequest litecoinSendRequest) { + Security.checkApiCallAllowed(request); + + if (litecoinSendRequest.litecoinAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (litecoinSendRequest.feePerByte != null && litecoinSendRequest.feePerByte <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidAddress(litecoinSendRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!litecoin.isValidDeterministicKey(litecoinSendRequest.xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Transaction spendTransaction = litecoin.buildSpend(litecoinSendRequest.xprv58, + litecoinSendRequest.receivingAddress, + litecoinSendRequest.litecoinAmount, + litecoinSendRequest.feePerByte); + + if (spendTransaction == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + try { + litecoin.broadcastTransaction(spendTransaction); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + + return spendTransaction.getTxId().toString(); + } + +} \ 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..fdd74b7d --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -0,0 +1,424 @@ +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.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.function.Supplier; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +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.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.CrossChainCancelRequest; +import org.qortal.api.model.CrossChainTradeSummary; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +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.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; +import org.qortal.transform.Transformer; +import org.qortal.transform.transaction.MessageTransactionTransformer; +import org.qortal.utils.Amounts; +import org.qortal.utils.Base58; +import org.qortal.utils.ByteArray; +import org.qortal.utils.NTP; + +@Path("/crosschain") +@Tag(name = "Cross-Chain") +public class CrossChainResource { + + @Context + HttpServletRequest request; + + @GET + @Path("/tradeoffers") + @Operation( + summary = "Find cross-chain trade offers", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public List getTradeOffers( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain, + @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); + + final boolean isExecutable = true; + List crossChainTradesData = new ArrayList<>(); + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); + + for (ATData atData : atsData) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + crossChainTradesData.add(crossChainTradeData); + } + } + + return crossChainTradesData; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/trade/{ataddress}") + @Operation( + summary = "Show detailed trade info", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + implementation = CrossChainTradeData.class + ) + ) + ) + } + ) + @ApiErrors({ApiError.ADDRESS_UNKNOWN, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public CrossChainTradeData getTrade(@PathParam("ataddress") String atAddress) { + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return acct.populateTradeData(repository, atData); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/trades") + @Operation( + summary = "Find completed cross-chain trades", + description = "Returns summary info about successfully completed cross-chain trades", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeSummary.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public List getCompletedTrades( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain, + @Parameter( + description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", + example = "1597310000000" + ) @QueryParam("minimumTimestamp") Long minimumTimestamp, + @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); + + // minimumTimestamp (if given) needs to be positive + if (minimumTimestamp != null && minimumTimestamp <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + final Boolean isFinished = Boolean.TRUE; + + try (final Repository repository = RepositoryManager.getRepository()) { + Integer minimumFinalHeight = null; + + if (minimumTimestamp != null) { + minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(minimumTimestamp); + + if (minimumFinalHeight == 0) + // We don't have any blocks since minimumTimestamp, let alone trades, so nothing to return + return Collections.emptyList(); + + // height returned from repository is for block BEFORE timestamp + // but we want trades AFTER timestamp so bump height accordingly + minimumFinalHeight++; + } + + List crossChainTrades = new ArrayList<>(); + + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumFinalHeight, + limit, offset, reverse); + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); + crossChainTrades.add(crossChainTradeSummary); + } + } + + return crossChainTrades; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/price/{blockchain}") + @Operation( + summary = "Request current estimated trading price", + description = "Returns price based on most recent completed trades. Price is expressed in terms of QORT per unit foreign currency.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public long getTradePriceEstimate( + @Parameter( + description = "foreign blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @PathParam("blockchain") SupportedBlockchain foreignBlockchain, + @Parameter( + description = "Maximum number of trades to include in price calculation", + example = "10", + schema = @Schema(type = "integer", defaultValue = "10") + ) @QueryParam("maxtrades") Integer maxtrades) { + // foreignBlockchain is required + if (foreignBlockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // We want both a minimum of 5 trades and enough trades to span at least 4 hours + int minimumCount = 5; + int maximumCount = maxtrades != null ? maxtrades : 10; + long minimumPeriod = 4 * 60 * 60 * 1000L; // ms + Boolean isFinished = Boolean.TRUE; + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + long totalForeign = 0; + long totalQort = 0; + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash, + isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, maximumCount, minimumPeriod); + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + totalForeign += crossChainTradeData.expectedForeignAmount; + totalQort += crossChainTradeData.qortAmount; + } + } + + return Amounts.scaledDivide(totalQort, totalForeign); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/tradeoffer") + @Operation( + summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", + description = "Specify address of cross-chain AT that needs to be cancelled.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored.
" + + "Performs MESSAGE proof-of-work.
" + + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainCancelRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String cancelTrade(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Does supplied public key match AT creator's public key? + if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + // Good to make MESSAGE + + String atCreatorAddress = Crypto.toAddress(creatorPublicKey); + byte[] messageData = acct.buildCancelMessage(atCreatorAddress); + + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData); + + return Base58.encode(messageTransactionBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + + private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { + long txTimestamp = NTP.getTime(); + + // senderPublicKey could be ephemeral trade public key where there is no corresponding account and hence no reference + String senderAddress = Crypto.toAddress(senderPublicKey); + byte[] lastReference = repository.getAccountRepository().getLastReference(senderAddress); + final boolean requiresPoW = lastReference == null; + + if (requiresPoW) { + Random random = new Random(); + lastReference = new byte[Transformer.SIGNATURE_LENGTH]; + random.nextBytes(lastReference); + } + + int version = 4; + int nonce = 0; + long amount = 0L; + Long assetId = null; // no assetId as amount is zero + Long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + if (requiresPoW) { + messageTransaction.computeNonce(); + } else { + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + } + + ValidationResult result = messageTransaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + try { + return MessageTransactionTransformer.toBytes(messageTransactionData); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java new file mode 100644 index 00000000..cd8766ca --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -0,0 +1,286 @@ +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 java.util.stream.Collectors; + +import javax.servlet.http.HttpServletRequest; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; + +import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrors; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.api.model.crosschain.TradeBotRespondRequest; +import org.qortal.asset.Asset; +import org.qortal.controller.tradebot.AcctTradeBot; +import org.qortal.controller.tradebot.TradeBot; +import org.qortal.crosschain.ForeignBlockchain; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@Path("/crosschain/tradebot") +@Tag(name = "Cross-Chain (Trade-Bot)") +public class CrossChainTradeBotResource { + + @Context + HttpServletRequest request; + + @GET + @Operation( + summary = "List current trade-bot states", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = TradeBotData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getTradeBotStates( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + + if (foreignBlockchain == null) + return allTradeBotData; + + return allTradeBotData.stream().filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain.name())).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/create") + @Operation( + summary = "Create a trade offer (trade-bot entry)", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotCreateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + Security.checkApiCallAllowed(request); + + if (tradeBotCreateRequest.foreignBlockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + ForeignBlockchain foreignBlockchain = tradeBotCreateRequest.foreignBlockchain.getInstance(); + + // We prefer foreignAmount to deprecated bitcoinAmount + if (tradeBotCreateRequest.foreignAmount == null) + tradeBotCreateRequest.foreignAmount = tradeBotCreateRequest.bitcoinAmount; + + if (!foreignBlockchain.isValidAddress(tradeBotCreateRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeBotCreateRequest.tradeTimeout < 60) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + try (final Repository repository = RepositoryManager.getRepository()) { + // Do some simple checking first + Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INSUFFICIENT_BALANCE); + + byte[] unsignedBytes = TradeBot.getInstance().createTrade(repository, tradeBotCreateRequest); + if (unsignedBytes == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return Base58.encode(unsignedBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/respond") + @Operation( + summary = "Respond to a trade offer. NOTE: WILL SPEND FUNDS!)", + description = "Start a new trade-bot entry to respond to chosen trade offer.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotRespondRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + final String atAddress = tradeBotRespondRequest.atAddress; + + // We prefer foreignKey to deprecated xprv58 + if (tradeBotRespondRequest.foreignKey == null) + tradeBotRespondRequest.foreignKey = tradeBotRespondRequest.xprv58; + + if (tradeBotRespondRequest.foreignKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, atAddress); + + // TradeBot uses AT's code hash to map to ACCT + ACCT acct = TradeBot.getInstance().getAcctUsingAtData(atData); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (!acct.getBlockchain().isValidWalletKey(tradeBotRespondRequest.foreignKey)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData, + tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress); + + switch (result) { + case OK: + return "true"; + + case BALANCE_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + case NETWORK_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + default: + return "false"; + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Operation( + summary = "Delete completed trade", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "93MB2qRDNVLxbmmPuYpLdAqn3u2x9ZhaVZK5wELHueP8" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String tradeBotDelete(String tradePrivateKey58) { + Security.checkApiCallAllowed(request); + + final byte[] tradePrivateKey; + try { + tradePrivateKey = Base58.decode(tradePrivateKey58); + + if (tradePrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + // Handed off to TradeBot + return TradeBot.getInstance().deleteEntry(repository, tradePrivateKey) ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // No point sending message to AT that's finished + if (atData.getIsFinished()) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + return atData; + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java b/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java new file mode 100644 index 00000000..26d131c4 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java @@ -0,0 +1,244 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.controller.Controller; +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +@WebSocket +@SuppressWarnings("serial") +public class PresenceWebSocket extends ApiWebSocket implements Listener { + + @XmlAccessorType(XmlAccessType.FIELD) + @SuppressWarnings("unused") + private static class PresenceInfo { + private final PresenceType presenceType; + private final String publicKey; + private final long timestamp; + private final String address; + + protected PresenceInfo() { + this.presenceType = null; + this.publicKey = null; + this.timestamp = 0L; + this.address = null; + } + + public PresenceInfo(PresenceType presenceType, String pubKey58, long timestamp) { + this.presenceType = presenceType; + this.publicKey = pubKey58; + this.timestamp = timestamp; + this.address = Crypto.toAddress(Base58.decode(this.publicKey)); + } + + public PresenceType getPresenceType() { + return this.presenceType; + } + + public String getPublicKey() { + return this.publicKey; + } + + public long getTimestamp() { + return this.timestamp; + } + + public String getAddress() { + return this.address; + } + } + + /** Outer map key is PresenceType (enum), inner map key is public key in base58, inner map value is timestamp */ + private static final Map> currentEntries = Collections.synchronizedMap(new EnumMap<>(PresenceType.class)); + + /** (Optional) PresenceType used for filtering by that Session. */ + private static final Map sessionPresenceTypes = Collections.synchronizedMap(new HashMap<>()); + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(PresenceWebSocket.class); + + try (final Repository repository = RepositoryManager.getRepository()) { + populateCurrentInfo(repository); + } catch (DataException e) { + // How to fail properly? + return; + } + + EventBus.INSTANCE.addListener(this::listen); + } + + @Override + public void listen(Event event) { + // We use NewBlockEvent as a proxy for 1-minute timer + if (!(event instanceof Controller.NewTransactionEvent) && !(event instanceof Controller.NewBlockEvent)) + return; + + removeOldEntries(); + + if (event instanceof Controller.NewBlockEvent) + // We only wanted a chance to cull old entries + return; + + TransactionData transactionData = ((Controller.NewTransactionEvent) event).getTransactionData(); + + if (transactionData.getType() != TransactionType.PRESENCE) + return; + + PresenceTransactionData presenceData = (PresenceTransactionData) transactionData; + PresenceType presenceType = presenceData.getPresenceType(); + + // Put/replace for this publickey making sure we keep newest timestamp + String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey()); + long ourTimestamp = presenceData.getTimestamp(); + long computedTimestamp = mergePresence(presenceType, pubKey58, ourTimestamp); + + if (computedTimestamp != ourTimestamp) + // nothing changed + return; + + List presenceInfo = Collections.singletonList(new PresenceInfo(presenceType, pubKey58, computedTimestamp)); + + // Notify sessions + for (Session session : getSessions()) { + PresenceType sessionPresenceType = sessionPresenceTypes.get(session); + + if (sessionPresenceType == null || sessionPresenceType == presenceType) + sendPresenceInfo(session, presenceInfo); + } + } + + @OnWebSocketConnect + @Override + public void onWebSocketConnect(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + List presenceTypes = queryParams.get("presenceType"); + + // We only support ONE presenceType + String presenceTypeName = presenceTypes == null || presenceTypes.isEmpty() ? null : presenceTypes.get(0); + + PresenceType presenceType = presenceTypeName == null ? null : PresenceType.fromString(presenceTypeName); + + // Make sure that if caller does give a presenceType, that it is a valid/known one. + if (presenceTypeName != null && presenceType == null) { + session.close(4003, "unknown presenceType: " + presenceTypeName); + return; + } + + // Save session's requested PresenceType, if given + if (presenceType != null) + sessionPresenceTypes.put(session, presenceType); + + List presenceInfo; + + synchronized (currentEntries) { + presenceInfo = currentEntries.entrySet().stream() + .filter(entry -> presenceType == null ? true : entry.getKey() == presenceType) + .flatMap(entry -> entry.getValue().entrySet().stream().map(innerEntry -> new PresenceInfo(entry.getKey(), innerEntry.getKey(), innerEntry.getValue()))) + .collect(Collectors.toList()); + } + + if (!sendPresenceInfo(session, presenceInfo)) { + session.close(4002, "websocket issue"); + return; + } + + super.onWebSocketConnect(session); + } + + @OnWebSocketClose + @Override + public void onWebSocketClose(Session session, int statusCode, String reason) { + // clean up + sessionPresenceTypes.remove(session); + + super.onWebSocketClose(session, statusCode, reason); + } + + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + /* ignored */ + } + + private boolean sendPresenceInfo(Session session, List presenceInfo) { + try { + StringWriter stringWriter = new StringWriter(); + marshall(stringWriter, presenceInfo); + + String output = stringWriter.toString(); + session.getRemote().sendStringByFuture(output); + } catch (IOException e) { + // No output this time? + return false; + } + + return true; + } + + private static void populateCurrentInfo(Repository repository) throws DataException { + // We want ALL PRESENCE transactions + + List presenceTransactionsData = repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, null); + + for (TransactionData transactionData : presenceTransactionsData) { + PresenceTransactionData presenceData = (PresenceTransactionData) transactionData; + + PresenceType presenceType = presenceData.getPresenceType(); + + // Put/replace for this publickey making sure we keep newest timestamp + String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey()); + long ourTimestamp = presenceData.getTimestamp(); + + mergePresence(presenceType, pubKey58, ourTimestamp); + } + } + + private static long mergePresence(PresenceType presenceType, String pubKey58, long ourTimestamp) { + Map typedPubkeyTimestamps = currentEntries.computeIfAbsent(presenceType, someType -> Collections.synchronizedMap(new HashMap<>())); + return typedPubkeyTimestamps.compute(pubKey58, (somePubKey58, currentTimestamp) -> (currentTimestamp == null || currentTimestamp < ourTimestamp) ? ourTimestamp : currentTimestamp); + } + + private static void removeOldEntries() { + long now = NTP.getTime(); + + currentEntries.entrySet().forEach(entry -> { + long expiryThreshold = now - entry.getKey().getLifetime(); + entry.getValue().entrySet().removeIf(pubkeyTimestamp -> pubkeyTimestamp.getValue() < expiryThreshold); + }); + } + +} diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java new file mode 100644 index 00000000..55969c6b --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -0,0 +1,157 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.controller.tradebot.TradeBot; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@WebSocket +@SuppressWarnings("serial") +public class TradeBotWebSocket extends ApiWebSocket implements Listener { + + /** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */ + private static final Map PREVIOUS_STATES = new HashMap<>(); + + private static final Map sessionBlockchain = Collections.synchronizedMap(new HashMap<>()); + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeBotWebSocket.class); + + try (final Repository repository = RepositoryManager.getRepository()) { + List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData(); + if (tradeBotEntries == null) + // How do we properly fail here? + return; + + PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue))); + } catch (DataException e) { + // No output this time + } + + EventBus.INSTANCE.addListener(this::listen); + } + + @Override + public void listen(Event event) { + if (!(event instanceof TradeBot.StateChangeEvent)) + return; + + TradeBotData tradeBotData = ((TradeBot.StateChangeEvent) event).getTradeBotData(); + String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey()); + + synchronized (PREVIOUS_STATES) { + Integer previousStateValue = PREVIOUS_STATES.get(tradePrivateKey58); + if (previousStateValue != null && previousStateValue == tradeBotData.getStateValue()) + // Not changed + return; + + PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue()); + } + + List tradeBotEntries = Collections.singletonList(tradeBotData); + + for (Session session : getSessions()) { + // Only send if this session has this/no preferred blockchain + String preferredBlockchain = sessionBlockchain.get(session); + + if (preferredBlockchain == null || preferredBlockchain.equals(tradeBotData.getForeignBlockchain())) + sendEntries(session, tradeBotEntries); + } + } + + @OnWebSocketConnect + @Override + public void onWebSocketConnect(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + + List foreignBlockchains = queryParams.get("foreignBlockchain"); + final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0); + + // Make sure blockchain (if any) is valid + if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) { + session.close(4003, "unknown blockchain: " + foreignBlockchain); + return; + } + + // save session's preferred blockchain (if any) + sessionBlockchain.put(session, foreignBlockchain); + + // Send all known trade-bot entries + try (final Repository repository = RepositoryManager.getRepository()) { + List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData(); + + // Optional filtering + if (foreignBlockchain != null) + tradeBotEntries = tradeBotEntries.stream() + .filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain)) + .collect(Collectors.toList()); + + if (!sendEntries(session, tradeBotEntries)) { + session.close(4002, "websocket issue"); + return; + } + } catch (DataException e) { + session.close(4001, "repository issue fetching trade-bot entries"); + return; + } + + super.onWebSocketConnect(session); + } + + @OnWebSocketClose + @Override + public void onWebSocketClose(Session session, int statusCode, String reason) { + // clean up + sessionBlockchain.remove(session); + + super.onWebSocketClose(session, statusCode, reason); + } + + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + /* ignored */ + } + + private boolean sendEntries(Session session, List tradeBotEntries) { + try { + StringWriter stringWriter = new StringWriter(); + marshall(stringWriter, tradeBotEntries); + + String output = stringWriter.toString(); + session.getRemote().sendStringByFuture(output); + } catch (IOException e) { + // No output this time? + return false; + } + + return true; + } + +} diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java new file mode 100644 index 00000000..186f79e3 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -0,0 +1,351 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.api.model.CrossChainOfferSummary; +import org.qortal.controller.Controller; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.ByteArray; +import org.qortal.utils.NTP; + +@WebSocket +@SuppressWarnings("serial") +public class TradeOffersWebSocket extends ApiWebSocket implements Listener { + + private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class); + + private static class CachedOfferInfo { + public final Map previousAtModes = new HashMap<>(); + + // OFFERING + public final Map currentSummaries = new HashMap<>(); + // REDEEMED/REFUNDED/CANCELLED + public final Map historicSummaries = new HashMap<>(); + } + // Manual synchronization + private static final Map cachedInfoByBlockchain = new HashMap<>(); + + private static final Predicate isHistoric = offerSummary + -> offerSummary.getMode() == AcctMode.REDEEMED + || offerSummary.getMode() == AcctMode.REFUNDED + || offerSummary.getMode() == AcctMode.CANCELLED; + + private static final Map sessionBlockchain = Collections.synchronizedMap(new HashMap<>()); + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeOffersWebSocket.class); + + try (final Repository repository = RepositoryManager.getRepository()) { + populateCurrentSummaries(repository); + + populateHistoricSummaries(repository); + } catch (DataException e) { + // How to fail properly? + return; + } + + EventBus.INSTANCE.addListener(this::listen); + } + + @Override + public void listen(Event event) { + if (!(event instanceof Controller.NewBlockEvent)) + return; + + BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData(); + + // Process any new info + + try (final Repository repository = RepositoryManager.getRepository()) { + // Find any new/changed trade ATs since this block + final Boolean isFinished = null; + final Integer dataByteOffset = null; + final Long expectedValue = null; + final Integer minimumFinalHeight = blockData.getHeight(); + + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); + + List crossChainOfferSummaries = new ArrayList<>(); + + synchronized (cachedInfoByBlockchain) { + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + crossChainOfferSummaries.addAll(produceSummaries(repository, acct, atStates, blockData.getTimestamp())); + } + + // Remove any entries unchanged from last time + crossChainOfferSummaries.removeIf(offerSummary -> cachedInfo.previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode()); + + // Skip to next blockchain if nothing has changed (for this blockchain) + if (crossChainOfferSummaries.isEmpty()) + continue; + + // Update + for (CrossChainOfferSummary offerSummary : crossChainOfferSummaries) { + String offerAtAddress = offerSummary.getQortalAtAddress(); + + cachedInfo.previousAtModes.put(offerAtAddress, offerSummary.getMode()); + LOGGER.trace(() -> String.format("Block height: %d, AT: %s, mode: %s", blockData.getHeight(), offerAtAddress, offerSummary.getMode().name())); + + switch (offerSummary.getMode()) { + case OFFERING: + cachedInfo.currentSummaries.put(offerAtAddress, offerSummary); + cachedInfo.historicSummaries.remove(offerAtAddress); + break; + + case REDEEMED: + case REFUNDED: + case CANCELLED: + cachedInfo.currentSummaries.remove(offerAtAddress); + cachedInfo.historicSummaries.put(offerAtAddress, offerSummary); + break; + + case TRADING: + cachedInfo.currentSummaries.remove(offerAtAddress); + cachedInfo.historicSummaries.remove(offerAtAddress); + break; + } + } + + // Remove any historic offers that are over 24 hours old + final long tooOldTimestamp = NTP.getTime() - 24 * 60 * 60 * 1000L; + cachedInfo.historicSummaries.values().removeIf(historicSummary -> historicSummary.getTimestamp() < tooOldTimestamp); + } + + // Notify sessions + for (Session session : getSessions()) { + // Only send if this session has this/no preferred blockchain + String preferredBlockchain = sessionBlockchain.get(session); + + if (preferredBlockchain == null || preferredBlockchain.equals(blockchain.name())) + sendOfferSummaries(session, crossChainOfferSummaries); + } + + } + } catch (DataException e) { + // No output this time + } + } + + @OnWebSocketConnect + @Override + public void onWebSocketConnect(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + final boolean includeHistoric = queryParams.get("includeHistoric") != null; + + List foreignBlockchains = queryParams.get("foreignBlockchain"); + final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0); + + // Make sure blockchain (if any) is valid + if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) { + session.close(4003, "unknown blockchain: " + foreignBlockchain); + return; + } + + // Save session's preferred blockchain, if given + if (foreignBlockchain != null) + sessionBlockchain.put(session, foreignBlockchain); + + List crossChainOfferSummaries = new ArrayList<>(); + + synchronized (cachedInfoByBlockchain) { + Collection cachedInfos; + + if (foreignBlockchain == null) + // No preferred blockchain, so iterate through all of them + cachedInfos = cachedInfoByBlockchain.values(); + else + cachedInfos = Collections.singleton(cachedInfoByBlockchain.computeIfAbsent(foreignBlockchain, k -> new CachedOfferInfo())); + + for (CachedOfferInfo cachedInfo : cachedInfos) { + crossChainOfferSummaries.addAll(cachedInfo.currentSummaries.values()); + + if (includeHistoric) + crossChainOfferSummaries.addAll(cachedInfo.historicSummaries.values()); + } + } + + if (!sendOfferSummaries(session, crossChainOfferSummaries)) { + session.close(4002, "websocket issue"); + return; + } + + super.onWebSocketConnect(session); + } + + @OnWebSocketClose + @Override + public void onWebSocketClose(Session session, int statusCode, String reason) { + // clean up + sessionBlockchain.remove(session); + + super.onWebSocketClose(session, statusCode, reason); + } + + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + /* ignored */ + } + + private boolean sendOfferSummaries(Session session, List crossChainOfferSummaries) { + try { + StringWriter stringWriter = new StringWriter(); + marshall(stringWriter, crossChainOfferSummaries); + + String output = stringWriter.toString(); + session.getRemote().sendStringByFuture(output); + } catch (IOException e) { + // No output this time? + return false; + } + + return true; + } + + private static void populateCurrentSummaries(Repository repository) throws DataException { + // We want ALL OFFERING trades + Boolean isFinished = Boolean.FALSE; + Long expectedValue = (long) AcctMode.OFFERING.value; + Integer minimumFinalHeight = null; + + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); + + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + Integer dataByteOffset = acct.getModeByteOffset(); + List initialAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (initialAtStates == null) + throw new DataException("Couldn't fetch current trades from repository"); + + // Save initial AT modes + cachedInfo.previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> AcctMode.OFFERING))); + + // Convert to offer summaries + cachedInfo.currentSummaries.putAll(produceSummaries(repository, acct, initialAtStates, null).stream() + .collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary))); + } + } + } + + private static void populateHistoricSummaries(Repository repository) throws DataException { + // We want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours + long timestamp = System.currentTimeMillis() - 24 * 60 * 60 * 1000L; + int minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp); + + if (minimumFinalHeight == 0) + throw new DataException("Couldn't fetch block timestamp from repository"); + + Boolean isFinished = Boolean.TRUE; + Integer dataByteOffset = null; + Long expectedValue = null; + ++minimumFinalHeight; // because height is just *before* timestamp + + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); + + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (historicAtStates == null) + throw new DataException("Couldn't fetch historic trades from repository"); + + for (ATStateData historicAtState : historicAtStates) { + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null); + + if (!isHistoric.test(historicOfferSummary)) + continue; + + // Add summary to initial burst + cachedInfo.historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary); + + // Save initial AT mode + cachedInfo.previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode()); + } + } + } + } + + private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + + long atStateTimestamp; + + if (crossChainTradeData.mode == AcctMode.OFFERING) + // We want when trade was created, not when it was last updated + atStateTimestamp = crossChainTradeData.creationTimestamp; + else + atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp); + } + + private static List produceSummaries(Repository repository, ACCT acct, List atStates, Long timestamp) throws DataException { + List offerSummaries = new ArrayList<>(); + + for (ATStateData atState : atStates) + offerSummaries.add(produceSummary(repository, acct, atState, timestamp)); + + return offerSummaries; + } + +} diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index 8f989e19..0d11e488 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -10,8 +10,10 @@ import org.ciyam.at.ExecutionException; import org.ciyam.at.FunctionData; import org.ciyam.at.IllegalFunctionCodeException; import org.ciyam.at.MachineState; +import org.qortal.crosschain.Bitcoin; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.TransactionData; +import org.qortal.settings.Settings; /** * Qortal-specific CIYAM-AT Functions. @@ -98,6 +100,19 @@ public enum QortalFunctionCode { setB(state, pkh); } }, + /** + * Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.
+ * 0x0511
+ * P2SH stored in lower 25 bytes of B. + */ + CONVERT_B_TO_P2SH(0x0511, 0, false) { + @Override + protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException { + byte addressPrefix = Settings.getInstance().getBitcoinNet() == Bitcoin.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4; + + convertAddressInB(addressPrefix, state); + } + }, /** * Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.
* 0x0512
diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index df6babd1..3d2bd48e 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -14,6 +14,8 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.tradebot.TradeBot; +import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; import org.qortal.data.block.BlockData; @@ -441,6 +443,9 @@ public class Controller extends Thread { blockMinter = new BlockMinter(); blockMinter.start(); + LOGGER.info("Starting trade-bot"); + TradeBot.getInstance(); + // Arbitrary transaction data manager LOGGER.info("Starting arbitrary-transaction data manager"); ArbitraryDataManager.getInstance().start(); diff --git a/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java new file mode 100644 index 00000000..84a0d484 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java @@ -0,0 +1,30 @@ +package org.qortal.controller.tradebot; + +import java.util.List; + +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +public interface AcctTradeBot { + + public enum ResponseResult { OK, BALANCE_ISSUE, NETWORK_ISSUE, TRADE_ALREADY_EXISTS } + + /** Returns list of state names for trade-bot entries that have ended, e.g. redeemed, refunded or cancelled. */ + public List getEndStates(); + + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException; + + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, + CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException; + + public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException; + + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException; + +} diff --git a/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java new file mode 100644 index 00000000..ca2e2518 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -0,0 +1,1273 @@ +package org.qortal.controller.tradebot; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.asset.Asset; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.DeployAtTransaction; +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; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class BitcoinACCTv1TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv1TradeBot.class); + + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, true), + BOB_WAITING_FOR_P2SH_B(20, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), + + ALICE_WAITING_FOR_P2SH_A(80, true, true), + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_WATCH_P2SH_B(90, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_B(100, true, true), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); + + public final int value; + public final boolean requiresAtData; + public final boolean requiresTradeData; + + State(int value, boolean requiresAtData, boolean requiresTradeData) { + this.value = value; + this.requiresAtData = requiresAtData; + this.requiresTradeData = requiresTradeData; + } + + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; + } + } + + /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ + private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms + + /** P2SH-B output amount to avoid dust threshold (3000 sats/kB). */ + private static final long P2SH_B_OUTPUT_AMOUNT = 1000L; + + private static BitcoinACCTv1TradeBot instance; + + private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDING_B, State.ALICE_REFUNDED).stream() + .map(State::name) + .collect(Collectors.toUnmodifiableList()); + + private BitcoinACCTv1TradeBot() { + } + + public static synchronized BitcoinACCTv1TradeBot getInstance() { + if (instance == null) + instance = new BitcoinACCTv1TradeBot(); + + return instance; + } + + @Override + public List getEndStates() { + return this.endStates; + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret-B
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Bitcoin) public key, public key hash
  • + *
  • HASH160 of secret-B
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • + *
  • 'foreign'/Bitcoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • HASH160 of secret-B - used by AT and P2SH to validate a potential secret-B
  • + *
  • QORT amount on offer by Bob
  • + *
  • BTC amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretB = TradeBot.generateSecret(); + byte[] hashOfSecretB = Crypto.hash160(secretB); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) + Address bitcoinReceivingAddress; + try { + bitcoinReceivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (bitcoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] bitcoinReceivingAccountInfo = bitcoinReceivingAddress.getHash(); + + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + // Deploy AT + long timestamp = NTP.getTime(); + byte[] reference = creator.getLastReference(); + long fee = 0L; + byte[] signature = null; + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); + + String name = "QORT/BTC ACCT"; + String description = "QORT/BTC cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT BTC"; + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); + long amount = tradeBotCreateRequest.fundingQortAmount; + + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, + creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretB, hashOfSecretB, + SupportedBlockchain.BITCOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + + // Return to user for signing and broadcast as we don't have their Qortal private key + try { + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + throw new DataException("Failed to transform DEPLOY_AT transaction?", e); + } + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching BTC to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Bitcoin wallet via xprv58. + *

+ * The crossChainTradeData contains the current trade offer state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Bitcoin BIP32 hierarchical deterministic key, + * passed via xprv58. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the xprv58 can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Bitcoin main-net) + * or 'tprv' for (Bitcoin test-net). + *

+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Bitcoin amount expected by 'Bob'. + *

+ * If the Bitcoin transaction is successfully broadcast to the network then the trade-bot entry + * is saved to the repository and the cross-chain trading process commences. + *

+ * Trade-bot will wait for P2SH-A to confirm before taking next step. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param xprv58 funded wallet xprv in base58 + * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH + + // We need to generate lockTime-A: add tradeTimeout to now + long now = NTP.getTime(); + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.ALICE_WAITING_FOR_P2SH_A.name(), State.ALICE_WAITING_FOR_P2SH_A.value, + receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + SupportedBlockchain.BITCOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); + + // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin + String tradeForeignAddress = Bitcoin.getInstance().pkhToAddress(tradeForeignPublicKeyHash); + + long p2shFee; + try { + p2shFee = Bitcoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Bitcoin fees?"); + return ResponseResult.NETWORK_ISSUE; + } + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long fundsRequiredForP2shA = p2shFee /*funding P2SH-A*/ + crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; + long fundsRequiredForP2shB = p2shFee /*funding P2SH-B*/ + P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/; + long totalFundsRequired = fundsRequiredForP2shA + fundsRequiredForP2shB; + + // As buildSpend also adds a fee, this is more pessimistic than required + Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); + if (fundingCheckTransaction == null) + return ResponseResult.BALANCE_ISSUE; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = Bitcoin.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Fund P2SH-A + + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; + + Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); + return ResponseResult.BALANCE_ISSUE; + } + + try { + Bitcoin.getInstance().broadcastTransaction(p2shFundingTransaction); + } catch (ForeignBlockchainException e) { + // We couldn't fund P2SH-A at this time + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); + return ResponseResult.NETWORK_ISSUE; + } + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + + return ResponseResult.OK; + } + + @Override + public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) + return true; + + // If the AT doesn't exist then we might as well let the user tidy up + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) + return true; + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + return true; + + default: + return false; + } + } + + @Override + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) { + LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress())); + return; + } + + ATData atData = null; + CrossChainTradeData tradeData = null; + + if (tradeBotState.requiresAtData) { + // Attempt to fetch AT data + atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + if (tradeBotState.requiresTradeData) { + tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + if (tradeData == null) { + LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress())); + return; + } + } + } + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; + + case ALICE_WAITING_FOR_P2SH_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_MESSAGE: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WAITING_FOR_AT_LOCK: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WATCH_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWatchingP2shB(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_DONE: + case BOB_DONE: + break; + + case ALICE_REFUNDING_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + } + } + + /** + * Trade-bot is waiting for Bob's AT to deploy. + *

+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. + */ + private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) { + if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD) + return; + + // We've waited ages for AT to be confirmed into a block but something has gone awry. + // After this long we assume transaction loss so give up with trade-bot entry too. + tradeBotData.setState(State.BOB_REFUNDED.name()); + tradeBotData.setStateValue(State.BOB_REFUNDED.value); + tradeBotData.setTimestamp(NTP.getTime()); + // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); + TradeBot.notifyStateChange(tradeBotData); + return; + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, + () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); + } + + /** + * Trade-bot is waiting for Alice's P2SH-A to confirm. + *

+ * If P2SH-A is confirmed, then trade-bot's next step is to MESSAGE Bob's trade address with Alice's trade info. + *

+ * It is possible between broadcast and confirmation of P2SH-A funding transaction, that Bob has cancelled his trade offer. + * If this is detected then trade-bot's next step is to wait until P2SH-A can refund back to Alice. + *

+ * In normal operation, trade-bot send a zero-fee, PoW MESSAGE on Alice's behalf containing: + *

    + *
  • Alice's 'foreign'/Bitcoin public key hash - so Bob's trade-bot can derive P2SH-A address and check balance
  • + *
  • HASH160 of Alice's secret-A - also used to derive P2SH-A address
  • + *
  • lockTime of P2SH-A - also used to derive P2SH-A address, but also for other use later in the trading process
  • + *
+ * If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. + * @throws ForeignBlockchainException + */ + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestampA = calcP2shAFeeTimestamp(tradeBotData.getLockTimeA(), crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, + () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + case FUNDED: + // Fall-through out of switch... + break; + } + + // P2SH-A funding confirmed + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = BitcoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_AT_LOCK, + () -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", + p2shAddressA, messageRecipient, tradeBotData.getAtAddress())); + } + + /** + * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. + *

+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, + * in which case trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. + *

+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. + *

+ * Assuming P2SH-A has at least expected Bitcoin balance, + * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. + *

+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. + *

+ * Trade-bot's next step is to wait for P2SH-B, which will allow Bob to reveal his secret-B, + * needed by Alice to progress her side of the trade. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + return; + } + + Bitcoin bitcoin = Bitcoin.getInstance(); + + String address = tradeBotData.getTradeNativeAddress(); + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); + + final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); + + // Skip past previously processed messages + if (originalLastTransactionSignature != null) + for (int i = 0; i < messageTransactionsData.size(); ++i) + if (Arrays.equals(messageTransactionsData.get(i).getSignature(), originalLastTransactionSignature)) { + messageTransactionsData.subList(0, i + 1).clear(); + break; + } + + while (!messageTransactionsData.isEmpty()) { + MessageTransactionData messageTransactionData = messageTransactionsData.remove(0); + tradeBotData.setLastTransactionSignature(messageTransactionData.getSignature()); + + if (messageTransactionData.isText()) + continue; + + // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); + + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + final long minimumAmountA = tradeBotData.getForeignAmount() - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // There might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, + () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case FUNDED: + // Fall-through out of switch... + break; + } + + // Good to go - send MESSAGE to AT + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume + byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, + () -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB)); + + return; + } + + // Don't resave/notify if we don't need to + if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) + TradeBot.updateTradeBotState(repository, tradeBotData, null); + } + + /** + * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. + *

+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow + * this process has taken so long that we've reached P2SH-A's locktime, or that someone else + * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. + *

+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. + *

+ * If all is well, trade-bot then uses Bitcoin wallet to (token) fund P2SH-B. + *

+ * If P2SH-B funding transaction is successfully broadcast to the Bitcoin network, trade-bot's next + * step is to watch for Bob revealing secret-B by redeeming P2SH-B. + * @throws ForeignBlockchainException + */ + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + int lockTimeA = tradeBotData.getLockTimeA(); + + // Refund P2SH-A if we've passed lockTime-A + if (NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); + + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // This shouldn't occur, but defensively revert back to waiting for P2SH-A + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_P2SH_A, + () -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA)); + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, + () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + case FUNDED: + // Fall-through out of switch... + break; + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> atData.getIsFinished() + ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) + : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA)); + + return; + } + + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != AcctMode.TRADING) + return; + + // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above + + // Alice needs to fund P2SH-B here + + // Find our MESSAGE to AT from previous state + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(), + crossChainTradeData.qortalCreatorTradeAddress, null, null, null); + if (messageTransactionsData == null || messageTransactionsData.isEmpty()) { + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp(); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(recipientMessageTimestamp, lockTimeA); + + // Our calculated lockTime-B should match AT's calculated lockTime-B + if (lockTimeB != crossChainTradeData.lockTimeB) { + LOGGER.debug(() -> String.format("Trade AT lockTime-B '%d' doesn't match our lockTime-B '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + // We'll eventually refund + return; + } + + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); + + long feeTimestampB = calcP2shBFeeTimestamp(lockTimeA, lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + + // Have we funded P2SH-B already? + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; + + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { + case UNFUNDED: { + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB /*redeeming/refunding P2SH-B*/; + + Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getForeignKey(), p2shAddressB, amountB); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?"); + return; + } + + bitcoin.broadcastTransaction(p2shFundingTransaction); + break; + } + + case FUNDING_IN_PROGRESS: + case FUNDED: + break; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, + () -> String.format("P2SH-B %s already spent? Defensively checking P2SH-B next", p2shAddressB)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); + return; + } + + // P2SH-B funded, now we wait for Bob to redeem it + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, + () -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", + tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB)); + } + + /** + * Trade-bot is waiting for P2SH-B to funded. + *

+ * It's possible than Bob's AT has reached it's trading timeout and automatically refunded QORT back to Bob. + * In which case, trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming P2SH-B is funded, trade-bot 'redeems' this P2SH using secret-B, thus revealing it to Alice. + *

+ * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // If we've passed AT refund timestamp then AT will have finished after auto-refunding + if (atData.getIsFinished()) { + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + // It's possible AT hasn't processed our previous MESSAGE yet and so lockTimeB won't be set + if (crossChainTradeData.lockTimeB == null) + // AT yet to process MESSAGE + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + + byte[] redeemScriptB = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); + + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; + + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-B to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, + () -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // AT should auto-refund - we don't need to do anything here + return; + + case FUNDED: + break; + } + + // Redeem P2SH-B using secret-B + Coin redeemAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. The real funds are in P2SH-A. + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo); + + bitcoin.broadcastTransaction(p2shRedeemTransaction); + + // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, + () -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); + } + + /** + * Trade-bot is waiting for Bob to redeem P2SH-B thus revealing secret-B to Alice. + *

+ * It's possible that this process has taken so long that we've reached P2SH-B's locktime. + * In which case, trade-bot switches to begin the refund process. + *

+ * If trade-bot can extract a valid secret-B from the spend of P2SH-B, then it creates a + * zero-fee, PoW MESSAGE to send to Bob's AT, including both secret-B and also Alice's secret-A. + *

+ * Both secrets are needed to release the QORT funds from Bob's AT to Alice's 'native'/Qortal + * trade address. + *

+ * In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A. + *

+ * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. + * @throws ForeignBlockchainException + */ + private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); + + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; + + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + case FUNDED: + case REDEEM_IN_PROGRESS: + // Still waiting for P2SH-B to be funded/redeemed... + return; + + case REDEEMED: + // Bob has redeemed P2SH-B, so double-check that we have redeemed AT... + break; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // We've refunded P2SH-B? Bump to refunding P2SH-A then + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); + return; + } + + byte[] secretB = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddressB); + if (secretB == null) + // Secret not revealed at this time + return; + + // Send 'redeem' MESSAGE to AT using both secrets + byte[] secretA = tradeBotData.getSecret(); + String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH + byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // Reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", + p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress)); + } + + /** + * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the BTC funds from P2SH-A. + *

+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, + * trade-bot is done with this specific trade and finalizes in refunded state. + *

+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the BTC funds from P2SH-A + * to Bob's 'foreign'/Bitcoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output). + *

+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // AT should be 'finished' once Alice has redeemed QORT funds + if (!atData.getIsFinished()) + // Not finished yet + return; + + // If AT is not REDEEMED then something has gone wrong + if (crossChainTradeData.mode != AcctMode.REDEEMED) { + // Not redeemed so must be refunded/cancelled + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = BitcoinACCTv1.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A + + Bitcoin bitcoin = Bitcoin.getInstance(); + int lockTimeA = crossChainTradeData.lockTimeA; + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Double-check that we have redeemed P2SH-A... + break; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // Wait for AT to auto-refund + return; + + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + bitcoin.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, + () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); + } + + /** + * Trade-bot is attempting to refund P2SH-B. + *

+ * We could potentially skip this step as P2SH-B is only funded with a token amount to cover the mining fee should Bob redeem P2SH-B. + *

+ * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. + * @throws ForeignBlockchainException + */ + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + int lockTimeB = crossChainTradeData.lockTimeB; + + // We can't refund P2SH-B until lockTime-B has passed + if (NTP.getTime() <= lockTimeB * 1000L) + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + + // We can't refund P2SH-B until median block time has passed lockTime-B (see BIP113) + int medianBlockTime = bitcoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeB) + return; + + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); + + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; + + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { + case UNFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s never funded?. Refunding P2SH-A next", p2shAddressB)); + return; + + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-B to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // We must be very close to trade timeout. Defensively try to refund P2SH-A + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptB, lockTimeB, receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); + break; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB)); + } + + /** + * Trade-bot is attempting to refund P2SH-A. + * @throws ForeignBlockchainException + */ + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + int lockTimeA = tradeBotData.getLockTimeA(); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTimeA * 1000L) + return; + + Bitcoin bitcoin = Bitcoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = bitcoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Too late! + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent!", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); + break; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); + } + + /** + * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. + *

+ * Will automatically update trade-bot state to ALICE_REFUNDING_B or ALICE_DONE as necessary. + * + * @throws DataException + * @throws ForeignBlockchainException + */ + private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // This is OK + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING) + return false; + + boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); + + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING && isAtLockedToUs) + return false; + + if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) { + // We've redeemed already? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress())); + } else { + // Any other state is not good, so start defensive refund + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_B, + () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); + } + + return true; + } + + private long calcP2shAFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } + + private long calcP2shBFeeTimestamp(int lockTimeA, int lockTimeB) { + // lockTimeB is halfway between offerMessageTimestamp and lockTimeA + return (lockTimeA - (lockTimeA - lockTimeB) * 2) * 1000L; + } + +} diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java new file mode 100644 index 00000000..0bd2972b --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -0,0 +1,894 @@ +package org.qortal.controller.tradebot; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.asset.Asset; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.DeployAtTransaction; +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; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class LitecoinACCTv1TradeBot implements AcctTradeBot { + + private static final Logger LOGGER = LogManager.getLogger(LitecoinACCTv1TradeBot.class); + + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), + + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); + + public final int value; + public final boolean requiresAtData; + public final boolean requiresTradeData; + + State(int value, boolean requiresAtData, boolean requiresTradeData) { + this.value = value; + this.requiresAtData = requiresAtData; + this.requiresTradeData = requiresTradeData; + } + + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; + } + } + + /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */ + private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms + + private static LitecoinACCTv1TradeBot instance; + + private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream() + .map(State::name) + .collect(Collectors.toUnmodifiableList()); + + private LitecoinACCTv1TradeBot() { + } + + public static synchronized LitecoinACCTv1TradeBot getInstance() { + if (instance == null) + instance = new LitecoinACCTv1TradeBot(); + + return instance; + } + + @Override + public List getEndStates() { + return this.endStates; + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for LTC. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Litecoin) public key, public key hash
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • + *
  • 'foreign'/Litecoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • QORT amount on offer by Bob
  • + *
  • LTC amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + // Convert Litecoin receiving address into public key hash (we only support P2PKH at this time) + Address litecoinReceivingAddress; + try { + litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + } + if (litecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress); + + byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash(); + + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + // Deploy AT + long timestamp = NTP.getTime(); + byte[] reference = creator.getLastReference(); + long fee = 0L; + byte[] signature = null; + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); + + String name = "QORT/LTC ACCT"; + String description = "QORT/LTC cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT LTC"; + byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); + long amount = tradeBotCreateRequest.fundingQortAmount; + + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME, + State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, + creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + null, null, + SupportedBlockchain.LITECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.foreignAmount, null, null, null, litecoinReceivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // Return to user for signing and broadcast as we don't have their Qortal private key + try { + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + throw new DataException("Failed to transform DEPLOY_AT transaction?", e); + } + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching LTC to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Litecoin wallet via xprv58. + *

+ * The crossChainTradeData contains the current trade offer state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Litecoin BIP32 hierarchical deterministic key, + * passed via xprv58. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the xprv58 can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Litecoin main-net) + * or 'tprv' for (Litecoin test-net). + *

+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Litecoin amount expected by 'Bob'. + *

+ * If the Litecoin transaction is successfully broadcast to the network then + * we also send a MESSAGE to Bob's trade-bot to let them know. + *

+ * The trade-bot entry is saved to the repository and the cross-chain trading process commences. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param xprv58 funded wallet xprv in base58 + * @return true if P2SH-A funding transaction successfully broadcast to Litecoin network, false otherwise + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH + + // We need to generate lockTime-A: add tradeTimeout to now + long now = NTP.getTime(); + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME, + State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value, + receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + SupportedBlockchain.LITECOIN.name(), + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); + + // Attempt to backup the trade bot data + TradeBot.backupTradeBotData(repository); + + // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount + long p2shFee; + try { + p2shFee = Litecoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate Litecoin fees?"); + return ResponseResult.NETWORK_ISSUE; + } + + // Fee for redeem/refund is subtracted from P2SH-A balance. + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = Litecoin.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = Litecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); + return ResponseResult.BALANCE_ISSUE; + } + + try { + Litecoin.getInstance().broadcastTransaction(p2shFundingTransaction); + } catch (ForeignBlockchainException e) { + // We couldn't fund P2SH-A at this time + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); + return ResponseResult.NETWORK_ISSUE; + } + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = LitecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + return ResponseResult.NETWORK_ISSUE; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); + + return ResponseResult.OK; + } + + @Override + public boolean canDelete(Repository repository, TradeBotData tradeBotData) throws DataException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) + return true; + + // If the AT doesn't exist then we might as well let the user tidy up + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) + return true; + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + return true; + + default: + return false; + } + } + + @Override + public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) { + LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress())); + return; + } + + ATData atData = null; + CrossChainTradeData tradeData = null; + + if (tradeBotState.requiresAtData) { + // Attempt to fetch AT data + atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.debug(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + if (tradeBotState.requiresTradeData) { + tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + if (tradeData == null) { + LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress())); + return; + } + } + } + + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_MESSAGE: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WAITING_FOR_AT_LOCK: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_DONE: + case BOB_DONE: + break; + + case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + } + } + + /** + * Trade-bot is waiting for Bob's AT to deploy. + *

+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. + */ + private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) { + if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD) + return; + + // We've waited ages for AT to be confirmed into a block but something has gone awry. + // After this long we assume transaction loss so give up with trade-bot entry too. + tradeBotData.setState(State.BOB_REFUNDED.name()); + tradeBotData.setStateValue(State.BOB_REFUNDED.value); + tradeBotData.setTimestamp(NTP.getTime()); + // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); + TradeBot.notifyStateChange(tradeBotData); + return; + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, + () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); + } + + /** + * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. + *

+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, + * in which case trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. + *

+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. + *

+ * Assuming P2SH-A has at least expected Litecoin balance, + * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. + *

+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. + *

+ * Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to + * extract secret-A needed to redeem Alice's P2SH. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + return; + } + + Litecoin litecoin = Litecoin.getInstance(); + + String address = tradeBotData.getTradeNativeAddress(); + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); + + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + if (messageTransactionData.isText()) + continue; + + // We're expecting: HASH160(secret-A), Alice's Litecoin pubkeyhash and lockTime-A + byte[] messageData = messageTransactionData.getData(); + LitecoinACCTv1.OfferMessageData offerMessageData = LitecoinACCTv1.extractOfferMessageData(messageData); + if (offerMessageData == null) + continue; + + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerLitecoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + long messageTimestamp = messageTransactionData.getTimestamp(); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA); + + // Determine P2SH-A address and confirm funded + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // There might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // We've already redeemed this? + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, + () -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case FUNDED: + // Fall-through out of switch... + break; + } + + // Good to go - send MESSAGE to AT + + 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 = LitecoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false); + + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, + () -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress)); + + return; + } + } + + /** + * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. + *

+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow + * this process has taken so long that we've reached P2SH-A's locktime, or that someone else + * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. + *

+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. + *

+ * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice. + *

+ * In revealing a valid secret-A, Bob can then redeem the LTC funds from P2SH-A. + *

+ * @throws ForeignBlockchainException + */ + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) + return; + + Litecoin litecoin = Litecoin.getInstance(); + int lockTimeA = tradeBotData.getLockTimeA(); + + // Refund P2SH-A if we've passed lockTime-A + if (NTP.getTime() >= lockTimeA * 1000L) { + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + case FUNDED: + break; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Already redeemed? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> atData.getIsFinished() + ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) + : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA)); + + return; + } + + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != AcctMode.TRADING) + return; + + // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above + + // Find our MESSAGE to AT from previous state + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(), + crossChainTradeData.qortalCreatorTradeAddress, null, null, null); + if (messageTransactionsData == null || messageTransactionsData.isEmpty()) { + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp(); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(recipientMessageTimestamp, lockTimeA); + + // Our calculated refundTimeout should match AT's refundTimeout + if (refundTimeout != crossChainTradeData.refundTimeout) { + LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout)); + // We'll eventually refund + return; + } + + // We're good to redeem AT + + // Send 'redeem' MESSAGE to AT using both secret + byte[] secretA = tradeBotData.getSecret(); + String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH + byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretA, qortalReceivingAddress); + String messageRecipient = tradeBotData.getAtAddress(); + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // Reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("Redeeming AT %s. Funds should arrive at %s", + tradeBotData.getAtAddress(), qortalReceivingAddress)); + } + + /** + * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the LTC funds from P2SH-A. + *

+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, + * trade-bot is done with this specific trade and finalizes in refunded state. + *

+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the LTC funds from P2SH-A + * to Bob's 'foreign'/Litecoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send LTC to any address of Bob's choosing by changing the transaction output). + *

+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + * @throws ForeignBlockchainException + */ + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // AT should be 'finished' once Alice has redeemed QORT funds + if (!atData.getIsFinished()) + // Not finished yet + return; + + // If AT is REFUNDED or CANCELLED then something has gone wrong + if (crossChainTradeData.mode == AcctMode.REFUNDED || crossChainTradeData.mode == AcctMode.CANCELLED) { + // Alice hasn't redeemed the QORT, so there is no point in trying to redeem the LTC + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + + byte[] secretA = LitecoinACCTv1.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A + + Litecoin litecoin = Litecoin.getInstance(); + + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Double-check that we have redeemed P2SH-A... + break; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // Wait for AT to auto-refund + return; + + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + litecoin.broadcastTransaction(p2shRedeemTransaction); + break; + } + } + + String receivingAddress = litecoin.pkhToAddress(receivingAccountInfo); + + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, + () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); + } + + /** + * Trade-bot is attempting to refund P2SH-A. + * @throws ForeignBlockchainException + */ + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + int lockTimeA = tradeBotData.getLockTimeA(); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTimeA * 1000L) + return; + + Litecoin litecoin = Litecoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = litecoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) + return; + + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Too late! + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent!", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + litecoin.broadcastTransaction(p2shRefundTransaction); + break; + } + } + + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, + () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); + } + + /** + * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. + *

+ * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE as necessary. + * + * @throws DataException + * @throws ForeignBlockchainException + */ + private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + // This is OK + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING) + return false; + + boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); + + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING) + if (isAtLockedToUs) { + // AT is trading with us - OK + return false; + } else { + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress)); + + return true; + } + + if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) { + // We've redeemed already? + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, + () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress())); + } else { + // Any other state is not good, so start defensive refund + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); + } + + return true; + } + + private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } + +} diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java new file mode 100644 index 00000000..fa3b599e --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -0,0 +1,373 @@ +package org.qortal.controller.tradebot; + +import java.awt.TrayIcon.MessageType; +import java.security.SecureRandom; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.util.Supplier; +import org.bitcoinj.core.ECKey; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; +import org.qortal.controller.Controller; +import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; +import org.qortal.event.Listener; +import org.qortal.group.Group; +import org.qortal.gui.SysTray; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.NTP; + +import com.google.common.primitives.Longs; + +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Foreign blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class TradeBot implements Listener { + + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); + private static final Random RANDOM = new SecureRandom(); + + public interface StateNameAndValueSupplier { + public String getState(); + public int getStateValue(); + } + + public static class StateChangeEvent implements Event { + private final TradeBotData tradeBotData; + + public StateChangeEvent(TradeBotData tradeBotData) { + this.tradeBotData = tradeBotData; + } + + public TradeBotData getTradeBotData() { + return this.tradeBotData; + } + } + + private static final Map, Supplier> acctTradeBotSuppliers = new HashMap<>(); + static { + acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); + } + + private static TradeBot instance; + + private final Map presenceTimestampsByAtAddress = Collections.synchronizedMap(new HashMap<>()); + + private TradeBot() { + EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); + } + + public static synchronized TradeBot getInstance() { + if (instance == null) + instance = new TradeBot(); + + return instance; + } + + public ACCT getAcctUsingAtData(ATData atData) { + byte[] codeHash = atData.getCodeHash(); + if (codeHash == null) + return null; + + return SupportedBlockchain.getAcctByCodeHash(codeHash); + } + + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ACCT acct = this.getAcctUsingAtData(atData); + if (acct == null) + return null; + + return acct.populateTradeData(repository, atData); + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, + * i.e. OFFERing QORT in exchange for foreign blockchain currency. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret(s)
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' public key, public key hash
  • + *
  • hash(es) of secret(s)
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native' (Qortal) 'trade' address - used to MESSAGE AT
  • + *
  • 'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain
  • + *
  • hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed
  • + *
  • QORT amount on offer by Bob
  • + *
  • foreign currency amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + // Fetch latest ACCT version for requested foreign blockchain + ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct(); + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) + return null; + + return acctTradeBot.createTrade(repository, tradeBotCreateRequest); + } + + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to an existing QORT offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param foreignKey foreign blockchain wallet key + * @throws DataException + */ + public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, + CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress())); + return ResponseResult.NETWORK_ISSUE; + } + + // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT. + if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(atData.getATAddress(), acctTradeBot.getEndStates())) + return ResponseResult.TRADE_ALREADY_EXISTS; + + return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress); + } + + public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException { + TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + if (tradeBotData == null) + // Can't delete what we don't have! + return false; + + boolean canDelete = false; + + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) + // We can't/no longer support this ACCT + canDelete = true; + else { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData); + } + + if (canDelete) { + repository.getCrossChainRepository().delete(tradePrivateKey); + repository.saveChanges(); + } + + return canDelete; + } + + @Override + public void listen(Event event) { + if (!(event instanceof Controller.NewBlockEvent)) + return; + + synchronized (this) { + List allTradeBotData; + + try (final Repository repository = RepositoryManager.getRepository()) { + allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + return; + } + + for (TradeBotData tradeBotData : allTradeBotData) + try (final Repository repository = RepositoryManager.getRepository()) { + // Find ACCT-specific trade-bot for this entry + ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName()); + if (acct == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName())); + continue; + } + + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName())); + continue; + } + + acctTradeBot.progress(repository, tradeBotData); + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + } catch (ForeignBlockchainException e) { + LOGGER.warn(() -> String.format("Foreign blockchain issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage())); + } + } + } + + /*package*/ static byte[] generateTradePrivateKey() { + // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. + // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. + return new ECKey().getPrivKeyBytes(); + } + + /*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + return PrivateKeyAccount.toPublicKey(privateKey); + } + + /*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { + return ECKey.fromPrivate(privateKey).getPubKey(); + } + + /*package*/ static byte[] generateSecret() { + byte[] secret = new byte[32]; + RANDOM.nextBytes(secret); + return secret; + } + + /*package*/ static void backupTradeBotData(Repository repository) { + // Attempt to backup the trade bot data. This an optional step and doesn't impact trading, so don't throw an exception on failure + try { + LOGGER.info("About to backup trade bot data..."); + repository.exportNodeLocalData(); + } catch (DataException e) { + LOGGER.info(String.format("Repository issue when exporting trade bot data: %s", e.getMessage())); + } + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, + String newState, int newStateValue, Supplier logMessageSupplier) throws DataException { + tradeBotData.setState(newState); + tradeBotData.setStateValue(newStateValue); + tradeBotData.setTimestamp(NTP.getTime()); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + if (Settings.getInstance().isTradebotSystrayEnabled()) + SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO); + + if (logMessageSupplier != null) + LOGGER.info(logMessageSupplier); + + LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState)); + + notifyStateChange(tradeBotData); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier); + } + + /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ + /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier logMessageSupplier) throws DataException { + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier); + } + + /*package*/ static void notifyStateChange(TradeBotData tradeBotData) { + StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); + EventBus.INSTANCE.notify(stateChangeEvent); + } + + /*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) { + Supplier acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass()); + if (acctTradeBotSupplier == null) + return null; + + return acctTradeBotSupplier.get(); + } + + // PRESENCE-related + /*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData, CrossChainTradeData tradeData) + throws DataException { + String atAddress = tradeBotData.getAtAddress(); + + PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + String signerAddress = tradeNativeAccount.getAddress(); + + /* + * There's no point in Alice trying to build a PRESENCE transaction + * for an AT that isn't locked to her, as other peers won't be able + * to validate the PRESENCE transaction as signing public key won't + * be visible. + */ + if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress)) + // Signer is neither Bob, nor Alice, or trade not yet locked to Alice + return; + + long now = NTP.getTime(); + long threshold = now - PresenceType.TRADE_BOT.getLifetime(); + + long timestamp = presenceTimestampsByAtAddress.compute(atAddress, (k, v) -> (v == null || v < threshold) ? now : v); + + // If timestamp hasn't been updated then nothing to do + if (timestamp != now) + return; + + int txGroupId = Group.NO_GROUP; + byte[] reference = new byte[TransactionTransformer.SIGNATURE_LENGTH]; + byte[] creatorPublicKey = tradeNativeAccount.getPublicKey(); + long fee = 0L; + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); + + int nonce = 0; + byte[] timestampSignature = tradeNativeAccount.sign(Longs.toByteArray(timestamp)); + + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature); + + PresenceTransaction presenceTransaction = new PresenceTransaction(repository, transactionData); + presenceTransaction.computeNonce(); + + presenceTransaction.sign(tradeNativeAccount); + + ValidationResult result = presenceTransaction.importAsUnconfirmed(); + if (result != ValidationResult.OK) + LOGGER.debug(() -> String.format("Unable to build trade-bot PRESENCE transaction for %s: %s", tradeBotData.getAtAddress(), result.name())); + } + +} diff --git a/src/main/java/org/qortal/crosschain/ACCT.java b/src/main/java/org/qortal/crosschain/ACCT.java new file mode 100644 index 00000000..e557a3e2 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ACCT.java @@ -0,0 +1,23 @@ +package org.qortal.crosschain; + +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; + +public interface ACCT { + + public byte[] getCodeBytesHash(); + + public int getModeByteOffset(); + + public ForeignBlockchain getBlockchain(); + + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException; + + public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException; + + public byte[] buildCancelMessage(String creatorQortalAddress); + +} diff --git a/src/main/java/org/qortal/crosschain/AcctMode.java b/src/main/java/org/qortal/crosschain/AcctMode.java new file mode 100644 index 00000000..21496032 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/AcctMode.java @@ -0,0 +1,21 @@ +package org.qortal.crosschain; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Map; + +public enum AcctMode { + OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4); + + public final int value; + private static final Map map = stream(AcctMode.values()).collect(toMap(mode -> mode.value, mode -> mode)); + + AcctMode(int value) { + this.value = value; + } + + public static AcctMode valueOf(int value) { + return map.get(value); + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java new file mode 100644 index 00000000..28275d6a --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -0,0 +1,190 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.params.MainNetParams; +import org.bitcoinj.params.RegTestParams; +import org.bitcoinj.params.TestNet3Params; +import org.qortal.crosschain.ElectrumX.Server; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.settings.Settings; + +public class Bitcoin extends Bitcoiny { + + public static final String CURRENCY_CODE = "BTC"; + + // Temporary values until a dynamic fee system is written. + private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees. + private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch + private static final long NEW_FEE_AMOUNT = 10_000L; + + private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST + + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + public enum BitcoinNet { + MAIN { + @Override + public NetworkParameters getParams() { + return MainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("128.0.190.26", Server.ConnectionType.SSL, 50002), + new Server("hodlers.beer", Server.ConnectionType.SSL, 50002), + new Server("electrumx.erbium.eu", Server.ConnectionType.TCP, 50001), + new Server("electrumx.erbium.eu", Server.ConnectionType.SSL, 50002), + new Server("btc.lastingcoin.net", Server.ConnectionType.SSL, 50002), + new Server("electrum.bitaroo.net", Server.ConnectionType.SSL, 50002), + new Server("bitcoin.grey.pw", Server.ConnectionType.SSL, 50002), + new Server("2electrumx.hopto.me", Server.ConnectionType.SSL, 56022), + new Server("185.64.116.15", Server.ConnectionType.SSL, 50002), + new Server("kirsche.emzy.de", Server.ConnectionType.SSL, 50002), + new Server("alviss.coinjoined.com", Server.ConnectionType.SSL, 50002), + new Server("electrum.emzy.de", Server.ConnectionType.SSL, 50002), + new Server("electrum.emzy.de", Server.ConnectionType.TCP, 50001), + new Server("vmd71287.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("btc.litepay.ch", Server.ConnectionType.SSL, 50002), + new Server("electrum.stippy.com", Server.ConnectionType.SSL, 50002), + new Server("xtrum.com", Server.ConnectionType.SSL, 50002), + new Server("electrum.acinq.co", Server.ConnectionType.SSL, 50002), + new Server("electrum2.taborsky.cz", Server.ConnectionType.SSL, 50002), + new Server("vmd63185.contaboserver.net", Server.ConnectionType.SSL, 50002), + new Server("electrum2.privateservers.network", Server.ConnectionType.SSL, 50002), + new Server("electrumx.alexridevski.net", Server.ConnectionType.SSL, 50002), + new Server("192.166.219.200", Server.ConnectionType.SSL, 50002), + new Server("2ex.digitaleveryware.com", Server.ConnectionType.SSL, 50002), + new Server("dxm.no-ip.biz", Server.ConnectionType.SSL, 50002), + new Server("caleb.vegas", Server.ConnectionType.SSL, 50002)); + } + + @Override + public String getGenesisHash() { + return "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + } + + @Override + public long getP2shFee(Long timestamp) { + // TODO: This will need to be replaced with something better in the near future! + if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP) + return OLD_FEE_AMOUNT; + + return NEW_FEE_AMOUNT; + } + }, + TEST3 { + @Override + public NetworkParameters getParams() { + return TestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002), + new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002), + new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002), + new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001), + new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002), + new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012)); + } + + @Override + public String getGenesisHash() { + return "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return RegTestParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002)); + } + + @Override + public String getGenesisHash() { + // This is unique to each regtest instance + return null; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }; + + public abstract NetworkParameters getParams(); + public abstract Collection getServers(); + public abstract String getGenesisHash(); + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + } + + private static Bitcoin instance; + + private final BitcoinNet bitcoinNet; + + // Constructors and instance + + private Bitcoin(BitcoinNet bitcoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.bitcoinNet = bitcoinNet; + + LOGGER.info(() -> String.format("Starting Bitcoin support using %s", this.bitcoinNet.name())); + } + + public static synchronized Bitcoin getInstance() { + if (instance == null) { + BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet(); + + BitcoinyBlockchainProvider electrumX = new ElectrumX("Bitcoin-" + bitcoinNet.name(), bitcoinNet.getGenesisHash(), bitcoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(bitcoinNet.getParams()); + + instance = new Bitcoin(bitcoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + } + + return instance; + } + + // Getters & setters + + public static synchronized void resetForTesting() { + instance = null; + } + + // Actual useful methods for use by other classes + + /** + * Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong + */ + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + return this.bitcoinNet.getP2shFee(timestamp); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java new file mode 100644 index 00000000..5118e103 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -0,0 +1,921 @@ +package org.qortal.crosschain; + +import static org.ciyam.at.OpCode.calcOffset; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.API; +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.asset.Asset; +import org.qortal.at.QortalFunctionCode; +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.MessageTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +/** + * Cross-chain trade AT + * + *

+ *

    + *
  • Bob generates Bitcoin & Qortal 'trade' keys, and secret-b + *
      + *
    • private key required to sign P2SH redeem tx
    • + *
    • private key could 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 deploys Qortal AT + *
      + *
    + *
  • + *
  • Alice finds Qortal AT and wants to trade + *
      + *
    • Alice generates Bitcoin & Qortal 'trade' keys
    • + *
    • Alice funds Bitcoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Bitcoin PKH
      • + *
      + *
    • + *
    + *
  • + *
  • Bob receives "offer" MESSAGE + *
      + *
    • Checks Alice's P2SH-A
    • + *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: + *
        + *
      • Alice's trade Qortal address
      • + *
      • Alice's trade Bitcoin PKH
      • + *
      • hash-of-secret-A
      • + *
      + *
    • + *
    + *
  • + *
  • Alice checks Qortal AT to confirm it's locked to her + *
      + *
    • Alice creates/funds Bitcoin P2SH-B
    • + *
    + *
  • + *
  • Bob checks P2SH-B is funded + *
      + *
    • Bob redeems P2SH-B using his Bitcoin trade key and secret-B
    • + *
    + *
  • + *
  • Alice scans P2SH-B redeem transaction to extract secret-B + *
      + *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
        + *
      • secret-A
      • + *
      • secret-B
      • + *
      • Qortal receiving address of her chosing
      • + *
      + *
    • + *
    • AT's QORT funds are sent to Qortal receiving address
    • + *
    + *
  • + *
  • Bob checks AT, extracts secret-A + *
      + *
    • Bob redeems P2SH-A using his Bitcoin trade key and secret-A
    • + *
    • P2SH-A BTC funds end up at Bitcoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class BitcoinACCTv1 implements ACCT { + + public static final String NAME = BitcoinACCTv1.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes + + public static final int SECRET_LENGTH = 32; + + /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ + private static final int MODE_VALUE_OFFSET = 68; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); + + public static class OfferMessageData { + public byte[] partnerBitcoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerBitcoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; + public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ + + 24 /*partner's Bitcoin PKH (padded from 20 to 24)*/ + + 8 /*lockTimeB*/ + + 24 /*hash of secret-A (padded from 20 to 24)*/ + + 8 /*lockTimeA*/; + public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; + public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + + private static BitcoinACCTv1 instance; + + private BitcoinACCTv1() { + } + + public static synchronized BitcoinACCTv1 getInstance() { + if (instance == null) + instance = new BitcoinACCTv1(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Bitcoin.getInstance(); + } + + /** + * Returns Qortal AT creation bytes for cross-chain trading AT. + *

+ * tradeTimeout (minutes) is the time window for the trade partner to send the + * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. + * + * @param creatorTradeAddress AT creator's trade Qortal address, also used for refunds + * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's trade Bitcoin public key + * @param hashOfSecretB 20-byte HASH160 of 32-byte secret-B + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param bitcoinAmount how much BTC the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { + // Labels for data segment addresses + int addrCounter = 0; + + // Constants (with corresponding dataByteBuffer.put*() calls below) + + final int addrCreatorTradeAddress1 = addrCounter++; + final int addrCreatorTradeAddress2 = addrCounter++; + final int addrCreatorTradeAddress3 = addrCounter++; + final int addrCreatorTradeAddress4 = addrCounter++; + + final int addrBitcoinPublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrHashOfSecretB = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrBitcoinAmount = addrCounter++; + final int addrTradeTimeout = addrCounter++; + + final int addrMessageTxnType = addrCounter++; + final int addrExpectedTradeMessageLength = addrCounter++; + final int addrExpectedRedeemMessageLength = addrCounter++; + + final int addrCreatorAddressPointer = addrCounter++; + final int addrHashOfSecretBPointer = addrCounter++; + final int addrQortalPartnerAddressPointer = addrCounter++; + final int addrMessageSenderPointer = addrCounter++; + + final int addrTradeMessagePartnerBitcoinPKHOffset = addrCounter++; + final int addrPartnerBitcoinPKHPointer = addrCounter++; + final int addrTradeMessageHashOfSecretAOffset = addrCounter++; + final int addrHashOfSecretAPointer = addrCounter++; + + final int addrRedeemMessageSecretBOffset = addrCounter++; + final int addrRedeemMessageReceivingAddressOffset = addrCounter++; + + final int addrMessageDataPointer = addrCounter++; + final int addrMessageDataLength = addrCounter++; + + final int addrPartnerReceivingAddressPointer = addrCounter++; + + final int addrEndOfConstants = addrCounter; + + // Variables + + final int addrCreatorAddress1 = addrCounter++; + final int addrCreatorAddress2 = addrCounter++; + final int addrCreatorAddress3 = addrCounter++; + final int addrCreatorAddress4 = addrCounter++; + + final int addrQortalPartnerAddress1 = addrCounter++; + final int addrQortalPartnerAddress2 = addrCounter++; + final int addrQortalPartnerAddress3 = addrCounter++; + final int addrQortalPartnerAddress4 = addrCounter++; + + final int addrLockTimeA = addrCounter++; + final int addrLockTimeB = addrCounter++; + final int addrRefundTimeout = addrCounter++; + final int addrRefundTimestamp = addrCounter++; + final int addrLastTxnTimestamp = addrCounter++; + final int addrBlockTimestamp = addrCounter++; + final int addrTxnType = addrCounter++; + final int addrResult = addrCounter++; + + final int addrMessageSender1 = addrCounter++; + final int addrMessageSender2 = addrCounter++; + final int addrMessageSender3 = addrCounter++; + final int addrMessageSender4 = addrCounter++; + + final int addrMessageLength = addrCounter++; + + final int addrMessageData = addrCounter; + addrCounter += 4; + + final int addrHashOfSecretA = addrCounter; + addrCounter += 4; + + final int addrPartnerBitcoinPKH = addrCounter; + addrCounter += 4; + + final int addrPartnerReceivingAddress = addrCounter; + addrCounter += 4; + + final int addrMode = addrCounter++; + assert addrMode == MODE_VALUE_OFFSET : "MODE_VALUE_OFFSET does not match addrMode"; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // AT creator's trade Qortal address, decoded from Base58 + assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; + byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); + dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); + + // Bitcoin public key hash + assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0)); + + // Hash of secret-B + assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Bitcoin amount + assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect"; + dataByteBuffer.putLong(bitcoinAmount); + + // Suggested trade timeout (minutes) + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); + + // We're only interested in MESSAGE transactions + assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; + dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); + + // Expected length of 'trade' MESSAGE data from AT creator + assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; + dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); + + // Expected length of 'redeem' MESSAGE data from trade partner + assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; + dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); + + // Index into data segment of AT creator's address, used by GET_B_IND + assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; + dataByteBuffer.putLong(addrCreatorAddress1); + + // Index into data segment of hash of secret B, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretB); + + // Index into data segment of partner's Qortal address, used by SET_B_IND + assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; + dataByteBuffer.putLong(addrQortalPartnerAddress1); + + // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND + assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; + dataByteBuffer.putLong(addrMessageSender1); + + // Offset into 'trade' MESSAGE data payload for extracting partner's Bitcoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerBitcoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Bitcoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerBitcoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerBitcoinPKH); + + // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Index into data segment to hash of secret A, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretA); + + // Offset into 'redeem' MESSAGE data payload for extracting secret-B + assert dataByteBuffer.position() == addrRedeemMessageSecretBOffset * MachineState.VALUE_SIZE : "addrRedeemMessageSecretBOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address + assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Source location and length for hashing any passed secret + assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; + dataByteBuffer.putLong(addrMessageData); + assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; + dataByteBuffer.putLong(32L); + + // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; + dataByteBuffer.putLong(addrPartnerReceivingAddress); + + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; + + // Code labels + Integer labelRefund = null; + + Integer labelTradeTxnLoop = null; + Integer labelCheckTradeTxn = null; + Integer labelCheckCancelTxn = null; + Integer labelNotTradeNorCancelTxn = null; + Integer labelCheckNonRefundTradeTxn = null; + Integer labelTradeTxnExtract = null; + Integer labelRedeemTxnLoop = null; + Integer labelCheckRedeemTxn = null; + Integer labelCheckRedeemTxnSender = null; + Integer labelCheckSecretB = null; + Integer labelPayout = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* 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, addrLastTxnTimestamp)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ + + /* Transaction processing loop */ + labelTradeTxnLoop = codeByteBuffer.position(); + + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrResult 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, labelCheckTradeTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTradeTxn = 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, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + + /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ + + // 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 message sender's address with AT creator's trade address. If they don't match, check for cancel situation. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + // Message sender's address matches AT creator's trade address so go process 'trade' message + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); + + /* Checking message sender for possible cancel message */ + labelCheckCancelTxn = codeByteBuffer.position(); + + // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + // Partner address is AT creator's address, so cancel offer and finish. + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + /* Not trade nor cancel message */ + labelNotTradeNorCancelTxn = codeByteBuffer.position(); + + // Loop to find another transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Possible switch-to-trade-mode message */ + labelCheckNonRefundTradeTxn = codeByteBuffer.position(); + + // Check 'trade' message we received has expected number of message bytes + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to info extraction code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); + // Message length didn't match - go back to finding another 'trade' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Extracting info from 'trade' MESSAGE transaction */ + labelTradeTxnExtract = codeByteBuffer.position(); + + // 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 addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); + + // Extract trade partner's Bitcoin public key hash (PKH) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset)); + // Extract partner's Bitcoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer)); + // Also extract lockTimeB + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeB)); + + // Grab next 32 bytes + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); + + // Extract hash-of-secret-a (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + // Extract lockTimeA (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); + + // Calculate trade refund timeout: (lockTimeA - lockTimeB) / 2 / 60 + codeByteBuffer.put(OpCode.SET_DAT.compile(addrRefundTimeout, addrLockTimeA)); // refundTimeout = lockTimeA + codeByteBuffer.put(OpCode.SUB_DAT.compile(addrRefundTimeout, addrLockTimeB)); // refundTimeout -= lockTimeB + codeByteBuffer.put(OpCode.DIV_VAL.compile(addrRefundTimeout, 2L * 60L)); // refundTimeout /= 2 * 60 + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); + + /* We are in 'trade mode' */ + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ + + // 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, labelRedeemTxnLoop))); + // 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 */ + labelRedeemTxnLoop = 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, addrLastTxnTimestamp)); + // 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, labelCheckRedeemTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckRedeemTxn = 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, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check message payload length */ + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to sender checking code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); + // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Check transaction's sender */ + labelCheckRedeemTxnSender = codeByteBuffer.position(); + + // 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, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check 'secret-A' in transaction's message */ + + // Extract secret-A from first 32 bytes of 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 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 addrHashOfSecretAPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); + // 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.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Check 'secret-B' in transaction's message */ + + labelCheckSecretB = codeByteBuffer.position(); + + // Extract secret-B from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageSecretBOffset)); + // 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 addrHashOfSecretBPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretBPointer)); + // 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.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Success! Pay arranged amount to receiving address */ + labelPayout = codeByteBuffer.position(); + + // Extract Qortal receiving address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); + // Pay AT's balance to receiving address + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); + // Set redeemed mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + 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(); + + // Set refunded mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), BitcoinACCTv1.CODE_BYTES_HASH) + : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes))); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { + byte[] addressBytes = new byte[25]; // for general use + String atAddress = atStateData.getATAddress(); + + CrossChainTradeData tradeData = new CrossChainTradeData(); + + tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name(); + tradeData.acctName = NAME; + + tradeData.qortalAtAddress = atAddress; + tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); + tradeData.creationTimestamp = creationTimestamp; + + Account atAccount = new Account(repository, atAddress); + tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); + + byte[] stateData = atStateData.getStateData(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); + dataByteBuffer.position(MachineState.HEADER_LENGTH); + + /* Constants */ + + // Skip creator's trade address + dataByteBuffer.get(addressBytes); + tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Creator's Bitcoin/foreign public key hash + tradeData.creatorForeignPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorForeignPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes + + // Hash of secret B + tradeData.hashOfSecretB = new byte[20]; + dataByteBuffer.get(tradeData.hashOfSecretB); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.hashOfSecretB.length); // skip to 32 bytes + + // Redeem payout + tradeData.qortAmount = dataByteBuffer.getLong(); + + // Expected BTC amount + tradeData.expectedForeignAmount = dataByteBuffer.getLong(); + + // Trade timeout + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + + // Skip MESSAGE transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'trade' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'redeem' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Qortal trade address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message sender + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for partner's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for partner's Qortal receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message data + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip message data length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + /* End of constants / begin variables */ + + // Skip AT creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Partner's trade address (if present) + dataByteBuffer.get(addressBytes); + String qortalRecipient = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Potential lockTimeA (if in trade mode) + int lockTimeA = (int) dataByteBuffer.getLong(); + + // Potential lockTimeB (if in trade mode) + int lockTimeB = (int) dataByteBuffer.getLong(); + + // AT refund timeout (probably only useful for debugging) + int refundTimeout = (int) dataByteBuffer.getLong(); + + // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) + long tradeRefundTimestamp = dataByteBuffer.getLong(); + + // Skip last transaction timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip block timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary result + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message sender + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Skip message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message data + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // 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 partner's Bitcoin PKH + byte[] partnerBitcoinPKH = new byte[20]; + dataByteBuffer.get(partnerBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes + + // Partner's receiving address (if present) + byte[] partnerReceivingAddress = new byte[25]; + dataByteBuffer.get(partnerReceivingAddress); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes + + // Trade AT's 'mode' + long modeValue = dataByteBuffer.getLong(); + AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL)); + + /* End of variables */ + + if (acctMode != null && acctMode != AcctMode.OFFERING) { + tradeData.mode = acctMode; + tradeData.refundTimeout = refundTimeout; + tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; + tradeData.qortalPartnerAddress = qortalRecipient; + tradeData.hashOfSecretA = hashOfSecretA; + tradeData.partnerForeignPKH = partnerBitcoinPKH; + tradeData.lockTimeA = lockTimeA; + tradeData.lockTimeB = lockTimeB; + + if (acctMode == AcctMode.REDEEMED) + tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); + } else { + tradeData.mode = AcctMode.OFFERING; + } + + tradeData.duplicateDeprecated(); + + return tradeData; + } + + /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); + } + + /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ + public static OfferMessageData extractOfferMessageData(byte[] messageData) { + if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) + return null; + + OfferMessageData offerMessageData = new OfferMessageData(); + offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); + offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); + + return offerMessageData; + } + + /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { + byte[] data = new byte[TRADE_MESSAGE_LENGTH]; + byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB); + + System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); + System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); + System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); + + return data; + } + + /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ + @Override + public byte[] buildCancelMessage(String creatorQortalAddress) { + byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); + + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); + + return data; + } + + /** Returns 'redeem' MESSAGE payload for trade partner/ to send to AT. */ + public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceivingAddress) { + byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; + byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(secretB, 0, data, 32, secretB.length); + System.arraycopy(qortalReceivingAddressBytes, 0, data, 64, qortalReceivingAddressBytes.length); + + return data; + } + + /** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ + public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) { + // lockTimeB is halfway between offerMessageTimestamp and lockTimeA + return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L); + } + + public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalPartnerAddress; + + // We don't have partner's public key so we check every message to AT + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find 'redeem' message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != REDEEM_MESSAGE_LENGTH) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract both secretA & secretB + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + byte[] secretB = new byte[32]; + System.arraycopy(messageData, 32, secretB, 0, secretB.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + byte[] hashOfSecretB = Crypto.hash160(secretB); + if (!Arrays.equals(hashOfSecretB, crossChainTradeData.hashOfSecretB)) + continue; + + return secretA; + } + + return null; + } + +} diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java new file mode 100644 index 00000000..fc98f959 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -0,0 +1,740 @@ +package org.qortal.crosschain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.InsufficientMoneyException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.UTXO; +import org.bitcoinj.core.UTXOProvider; +import org.bitcoinj.core.UTXOProviderException; +import org.bitcoinj.crypto.ChildNumber; +import org.bitcoinj.crypto.DeterministicHierarchy; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.script.Script.ScriptType; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.wallet.DeterministicKeyChain; +import org.bitcoinj.wallet.SendRequest; +import org.bitcoinj.wallet.Wallet; +import org.qortal.api.model.SimpleForeignTransaction; +import org.qortal.crypto.Crypto; +import org.qortal.utils.Amounts; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; + +/** Bitcoin-like (Bitcoin, Litecoin, etc.) support */ +public abstract class Bitcoiny implements ForeignBlockchain { + + protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class); + + public static final int HASH160_LENGTH = 20; + + protected final BitcoinyBlockchainProvider blockchain; + protected final Context bitcoinjContext; + protected final String currencyCode; + + protected final NetworkParameters params; + + /** Keys that have been previously marked as fully spent,
+ * i.e. keys with transactions but with no unspent outputs. */ + protected final Set spentKeys = Collections.synchronizedSet(new HashSet<>()); + + /** How many bitcoinj wallet keys to generate in each batch. */ + private static final int WALLET_KEY_LOOKAHEAD_INCREMENT = 3; + + /** Byte offset into raw block headers to block timestamp. */ + private static final int TIMESTAMP_OFFSET = 4 + 32 + 32; + + // Constructors and instance + + protected Bitcoiny(BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + this.blockchain = blockchain; + this.bitcoinjContext = bitcoinjContext; + this.currencyCode = currencyCode; + + this.params = this.bitcoinjContext.getParams(); + } + + // Getters & setters + + public BitcoinyBlockchainProvider getBlockchainProvider() { + return this.blockchain; + } + + public Context getBitcoinjContext() { + return this.bitcoinjContext; + } + + public String getCurrencyCode() { + return this.currencyCode; + } + + public NetworkParameters getNetworkParameters() { + return this.params; + } + + // Interface obligations + + @Override + public boolean isValidAddress(String address) { + try { + ScriptType addressType = Address.fromString(this.params, address).getOutputScriptType(); + + return addressType == ScriptType.P2PKH || addressType == ScriptType.P2SH; + } catch (AddressFormatException e) { + return false; + } + } + + @Override + public boolean isValidWalletKey(String walletKey) { + return this.isValidDeterministicKey(walletKey); + } + + // Actual useful methods for use by other classes + + public String format(Coin amount) { + return this.format(amount.value); + } + + public String format(long amount) { + return Amounts.prettyAmount(amount) + " " + this.currencyCode; + } + + public boolean isValidDeterministicKey(String key58) { + try { + Context.propagate(this.bitcoinjContext); + DeterministicKey.deserializeB58(null, key58, this.params); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + /** Returns P2PKH address using passed public key hash. */ + public String pkhToAddress(byte[] publicKeyHash) { + Context.propagate(this.bitcoinjContext); + return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString(); + } + + /** Returns P2SH address using passed redeem script. */ + public String deriveP2shAddress(byte[] redeemScriptBytes) { + Context.propagate(bitcoinjContext); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + return LegacyAddress.fromScriptHash(this.params, redeemScriptHash).toString(); + } + + /** + * Returns median timestamp from latest 11 blocks, in seconds. + *

+ * @throws ForeignBlockchainException if error occurs + */ + public int getMedianBlockTime() throws ForeignBlockchainException { + int height = this.blockchain.getCurrentHeight(); + + // Grab latest 11 blocks + List blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11); + if (blockHeaders.size() < 11) + throw new ForeignBlockchainException("Not enough blocks to determine median block time"); + + List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); + + // Descending order + blockTimestamps.sort((a, b) -> Integer.compare(b, a)); + + // Pick median + return blockTimestamps.get(5); + } + + /** Returns fee per transaction KB. To be overridden for testnet/regtest. */ + public Coin getFeePerKb() { + return this.bitcoinjContext.getFeePerKb(); + } + + /** + * Returns fixed P2SH spending fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes + * @throws ForeignBlockchainException if something went wrong + */ + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + + /** + * Returns confirmed balance, based on passed payment script. + *

+ * @return confirmed balance, or zero if script unknown + * @throws ForeignBlockchainException if there was an error + */ + public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException { + return this.blockchain.getConfirmedBalance(addressToScriptPubKey(base58Address)); + } + + /** + * Returns list of unspent outputs pertaining to passed address. + *

+ * @return list of unspent outputs, or empty list if address unknown + * @throws ForeignBlockchainException if there was an error. + */ + // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead + public List getUnspentOutputs(String base58Address) throws ForeignBlockchainException { + List unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false); + + List unspentTransactionOutputs = new ArrayList<>(); + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = this.getOutputs(unspentOutput.hash); + + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); + } + + return unspentTransactionOutputs; + } + + /** + * Returns list of outputs pertaining to passed transaction hash. + *

+ * @return list of outputs, or empty list if transaction unknown + * @throws ForeignBlockchainException if there was an error. + */ + // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead + public List getOutputs(byte[] txHash) throws ForeignBlockchainException { + byte[] rawTransactionBytes = this.blockchain.getRawTransaction(txHash); + + Context.propagate(bitcoinjContext); + Transaction transaction = new Transaction(this.params, rawTransactionBytes); + return transaction.getOutputs(); + } + + /** + * Returns list of transaction hashes pertaining to passed address. + *

+ * @return list of unspent outputs, or empty list if script unknown + * @throws ForeignBlockchainException if there was an error. + */ + public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException { + return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed); + } + + /** + * Returns list of raw, confirmed transactions involving given address. + *

+ * @throws ForeignBlockchainException if there was an error + */ + public List getAddressTransactions(String base58Address) throws ForeignBlockchainException { + List transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false); + + List rawTransactions = new ArrayList<>(); + for (TransactionHash transactionInfo : transactionHashes) { + byte[] rawTransaction = this.blockchain.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes()); + rawTransactions.add(rawTransaction); + } + + return rawTransactions; + } + + /** + * Returns transaction info for passed transaction hash. + *

+ * @throws ForeignBlockchainException.NotFoundException if transaction unknown + * @throws ForeignBlockchainException if error occurs + */ + public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException { + return this.blockchain.getTransaction(txHash); + } + + /** + * Broadcasts raw transaction to network. + *

+ * @throws ForeignBlockchainException if error occurs + */ + public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException { + this.blockchain.broadcastTransaction(transaction.bitcoinSerialize()); + } + + /** + * Returns bitcoinj transaction sending amount to recipient. + * + * @param xprv58 BIP32 private key + * @param recipient P2PKH address + * @param amount unscaled amount + * @param feePerByte unscaled fee per byte, or null to use default fees + * @return transaction, or null if insufficient funds + */ + public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) { + Context.propagate(bitcoinjContext); + + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Address destination = Address.fromString(this.params, recipient); + SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); + + if (feePerByte != null) + sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024 + else + // Allow override of default for TestNet3, etc. + sendRequest.feePerKb = this.getFeePerKb(); + + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } + + /** + * Returns bitcoinj transaction sending amount to recipient using default fees. + * + * @param xprv58 BIP32 private key + * @param recipient P2PKH address + * @param amount unscaled amount + * @return transaction, or null if insufficient funds + */ + public Transaction buildSpend(String xprv58, String recipient, long amount) { + return buildSpend(xprv58, recipient, amount, null); + } + + /** + * Returns unspent Bitcoin balance given 'm' BIP32 key. + * + * @param key58 BIP32/HD extended Bitcoin private/public key + * @return unspent BTC balance, or null if unable to determine balance + */ + public Long getWalletBalance(String key58) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; + } + + public List getWalletTransactions(String key58) throws ForeignBlockchainException { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set walletTransactions = new HashSet<>(); + Set keySet = new HashSet<>(); + + int ki = 0; + do { + boolean areAllKeysUnused = true; + + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + keySet.add(address.toString()); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + + for (TransactionHash transactionHash : historicTransactionHashes) + walletTransactions.add(this.getTransaction(transactionHash.txHash)); + } + } + + if (areAllKeysUnused) + // No transactions for this batch of keys so assume we're done searching. + break; + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + + Comparator newestTimestampFirstComparator = Comparator.comparingInt(SimpleTransaction::getTimestamp).reversed(); + + return walletTransactions.stream().map(t -> convertToSimpleTransaction(t, keySet)).sorted(newestTimestampFirstComparator).collect(Collectors.toList()); + } + + protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { + long amount = 0; + long total = 0L; + for (BitcoinyTransaction.Input input : t.inputs) { + try { + BitcoinyTransaction t2 = getTransaction(input.outputTxHash); + List senders = t2.outputs.get(input.outputVout).addresses; + for (String sender : senders) { + if (keySet.contains(sender)) { + total += t2.outputs.get(input.outputVout).value; + } + } + } catch (ForeignBlockchainException e) { + LOGGER.trace("Failed to retrieve transaction information {}", input.outputTxHash); + } + } + if (t.outputs != null && !t.outputs.isEmpty()) { + for (BitcoinyTransaction.Output output : t.outputs) { + for (String address : output.addresses) { + if (keySet.contains(address)) { + if (total > 0L) { + amount -= (total - output.value); + } else { + amount += output.value; + } + } + } + } + } + return new SimpleTransaction(t.txHash, t.timestamp, amount); + } + + /** + * Returns first unused receive address given 'm' BIP32 key. + * + * @param key58 BIP32/HD extended Bitcoin private/public key + * @return P2PKH address + * @throws ForeignBlockchainException if something went wrong + */ + public String getUnusedReceiveAddress(String key58) throws ForeignBlockchainException { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + final int keyChainPathSize = keyChain.getAccountPath().size(); + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + int ki = 0; + do { + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + List dKeyPath = dKey.getPath(); + + // If keyChain is based on 'm', then make sure dKey is m/0/ki - i.e. a 'receive' address, not 'change' (m/1/ki) + if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO) + continue; + + // Check unspent + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List unspentOutputs = this.blockchain.getUnspentOutputs(script, false); + + /* + * If there are no unspent outputs then either: + * a) all the outputs have been spent + * b) address has never been used + * + * For case (a) we want to remember not to check this address (key) again. + */ + + if (unspentOutputs.isEmpty()) { + // If this is a known key that has been spent before, then we can skip asking for transaction history + if (this.spentKeys.contains(dKey)) { + wallet.getActiveKeyChain().markKeyAsUsed(dKey); + continue; + } + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + // Fully spent key - case (a) + this.spentKeys.add(dKey); + wallet.getActiveKeyChain().markKeyAsUsed(dKey); + continue; + } + + // Key never been used - case (b) + return address.toString(); + } + + // Key has unspent outputs, hence used, so no good to us + this.spentKeys.remove(dKey); + } + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + } + + // UTXOProvider support + + static class WalletAwareUTXOProvider implements UTXOProvider { + private final Bitcoiny bitcoiny; + private final Wallet wallet; + + private final DeterministicKeyChain keyChain; + + public WalletAwareUTXOProvider(Bitcoiny bitcoiny, Wallet wallet) { + this.bitcoiny = bitcoiny; + this.wallet = wallet; + this.keyChain = this.wallet.getActiveKeyChain(); + + // Set up wallet's key chain + this.keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + this.keyChain.maybeLookAhead(); + } + + @Override + public List getOpenTransactionOutputs(List keys) throws UTXOProviderException { + List allUnspentOutputs = new ArrayList<>(); + final boolean coinbase = false; + + int ki = 0; + do { + boolean areAllKeysUnspent = true; + + for (; ki < keys.size(); ++ki) { + ECKey key = keys.get(ki); + + Address address = Address.fromKey(this.bitcoiny.params, key, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List unspentOutputs; + try { + unspentOutputs = this.bitcoiny.blockchain.getUnspentOutputs(script, false); + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address)); + } + + /* + * If there are no unspent outputs then either: + * a) all the outputs have been spent + * b) address has never been used + * + * For case (a) we want to remember not to check this address (key) again. + */ + + if (unspentOutputs.isEmpty()) { + // If this is a known key that has been spent before, then we can skip asking for transaction history + if (this.bitcoiny.spentKeys.contains(key)) { + this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + areAllKeysUnspent = false; + continue; + } + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes; + try { + historicTransactionHashes = this.bitcoiny.blockchain.getAddressTransactions(script, false); + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address)); + } + + if (!historicTransactionHashes.isEmpty()) { + // Fully spent key - case (a) + this.bitcoiny.spentKeys.add(key); + this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + areAllKeysUnspent = false; + } else { + // Key never been used - case (b) + } + + continue; + } + + // If we reach here, then there's definitely at least one unspent key + this.bitcoiny.spentKeys.remove(key); + + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs; + try { + transactionOutputs = this.bitcoiny.getOutputs(unspentOutput.hash); + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s", + HashCode.fromBytes(unspentOutput.hash))); + } + + TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index); + + UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index, + Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase, + transactionOutput.getScriptPubKey()); + + allUnspentOutputs.add(utxo); + } + } + + if (areAllKeysUnspent) + // No transactions for this batch of keys so assume we're done searching. + return allUnspentOutputs; + + // Generate some more keys + keys.addAll(Bitcoiny.generateMoreKeys(this.keyChain)); + + // Process new keys + } while (true); + } + + @Override + public int getChainHeadHeight() throws UTXOProviderException { + try { + return this.bitcoiny.blockchain.getCurrentHeight(); + } catch (ForeignBlockchainException e) { + throw new UTXOProviderException("Unable to determine Bitcoiny chain height"); + } + } + + @Override + public NetworkParameters getParams() { + return this.bitcoiny.params; + } + } + + // Utility methods for others + + public static List simplifyWalletTransactions(List transactions) { + // Sort by oldest timestamp first + transactions.sort(Comparator.comparingInt(t -> t.timestamp)); + + // Manual 2nd-level sort same-timestamp transactions so that a transaction's input comes first + int fromIndex = 0; + do { + int timestamp = transactions.get(fromIndex).timestamp; + + int toIndex; + for (toIndex = fromIndex + 1; toIndex < transactions.size(); ++toIndex) + if (transactions.get(toIndex).timestamp != timestamp) + break; + + // Process same-timestamp sub-list + List subList = transactions.subList(fromIndex, toIndex); + + // Only if necessary + if (subList.size() > 1) { + // Quick index lookup + Map indexByTxHash = subList.stream().collect(Collectors.toMap(t -> t.txHash, t -> t.timestamp)); + + int restartIndex = 0; + boolean isSorted; + do { + isSorted = true; + + for (int ourIndex = restartIndex; ourIndex < subList.size(); ++ourIndex) { + BitcoinyTransaction ourTx = subList.get(ourIndex); + + for (BitcoinyTransaction.Input input : ourTx.inputs) { + Integer inputIndex = indexByTxHash.get(input.outputTxHash); + + if (inputIndex != null && inputIndex > ourIndex) { + // Input tx is currently after current tx, so swap + BitcoinyTransaction tmpTx = subList.get(inputIndex); + subList.set(inputIndex, ourTx); + subList.set(ourIndex, tmpTx); + + // Update index lookup too + indexByTxHash.put(ourTx.txHash, inputIndex); + indexByTxHash.put(tmpTx.txHash, ourIndex); + + if (isSorted) + restartIndex = Math.max(restartIndex, ourIndex); + + isSorted = false; + break; + } + } + } + } while (!isSorted); + } + + fromIndex = toIndex; + } while (fromIndex < transactions.size()); + + // Simplify + List simpleTransactions = new ArrayList<>(); + + // Quick lookup of txs in our wallet + Set walletTxHashes = transactions.stream().map(t -> t.txHash).collect(Collectors.toSet()); + + for (BitcoinyTransaction transaction : transactions) { + SimpleForeignTransaction.Builder builder = new SimpleForeignTransaction.Builder(); + builder.txHash(transaction.txHash); + builder.timestamp(transaction.timestamp); + + builder.isSentNotReceived(false); + + for (BitcoinyTransaction.Input input : transaction.inputs) { + // TODO: add input via builder + + if (walletTxHashes.contains(input.outputTxHash)) + builder.isSentNotReceived(true); + } + + for (BitcoinyTransaction.Output output : transaction.outputs) + builder.output(output.addresses, output.value); + + simpleTransactions.add(builder.build()); + } + + return simpleTransactions; + } + + // Utility methods for us + + protected static List generateMoreKeys(DeterministicKeyChain keyChain) { + int existingLeafKeyCount = keyChain.getLeafKeys().size(); + + // Increase lookahead size... + keyChain.setLookaheadSize(keyChain.getLookaheadSize() + Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + // ...and lookahead threshold (minimum number of keys to generate)... + keyChain.setLookaheadThreshold(0); + // ...so that this call will generate more keys + keyChain.maybeLookAhead(); + + // This returns *all* keys + List allLeafKeys = keyChain.getLeafKeys(); + + // Only return newly generated keys + return allLeafKeys.subList(existingLeafKeyCount, allLeafKeys.size()); + } + + protected byte[] addressToScriptPubKey(String base58Address) { + Context.propagate(this.bitcoinjContext); + Address address = Address.fromString(this.params, base58Address); + return ScriptBuilder.createOutputScript(address).getProgram(); + } + + protected Wallet walletFromDeterministicKey58(String key58) { + DeterministicKey dKey = DeterministicKey.deserializeB58(null, key58, this.params); + + if (dKey.hasPrivKey()) + return Wallet.fromSpendingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + else + return Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java new file mode 100644 index 00000000..7691efb1 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java @@ -0,0 +1,40 @@ +package org.qortal.crosschain; + +import java.util.List; + +public abstract class BitcoinyBlockchainProvider { + + public static final boolean INCLUDE_UNCONFIRMED = true; + public static final boolean EXCLUDE_UNCONFIRMED = false; + + /** Returns ID unique to bitcoiny network (e.g. "Litecoin-TEST3") */ + public abstract String getNetId(); + + /** Returns current blockchain height. */ + public abstract int getCurrentHeight() throws ForeignBlockchainException; + + /** Returns a list of raw block headers, starting at startHeight (inclusive), up to count max. */ + public abstract List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException; + + /** Returns balance of address represented by scriptPubKey. */ + public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException; + + /** Returns raw, serialized, transaction bytes given txHash. */ + public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException; + + /** Returns raw, serialized, transaction bytes given txHash. */ + public abstract byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException; + + /** Returns unpacked transaction given txHash. */ + public abstract BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException; + + /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */ + public abstract List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException; + + /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */ + public abstract List getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException; + + /** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */ + public abstract void broadcastTransaction(byte[] rawTransaction) throws ForeignBlockchainException; + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java new file mode 100644 index 00000000..8ebfffa2 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java @@ -0,0 +1,438 @@ +package org.qortal.crosschain; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.Transaction.SigHash; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; +import org.bitcoinj.script.ScriptChunk; +import org.bitcoinj.script.ScriptOpCodes; +import org.qortal.crypto.Crypto; +import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +public class BitcoinyHTLC { + + public enum Status { + UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED + } + + public static final int SECRET_LENGTH = 32; + public static final int MIN_LOCKTIME = 1500000000; + + public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL; + public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1; + + // Assuming node's trade-bot has no more than 100 entries? + private static final int MAX_CACHE_ENTRIES = 100; + + // Max time-to-live for cache entries (milliseconds) + private static final long CACHE_TIMEOUT = 30_000L; + + @SuppressWarnings("serial") + private static final Map SECRET_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0]; + + @SuppressWarnings("serial") + private static final Map STATUS_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + + /* + * OP_TUCK (to copy public key to before signature) + * OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails) + * OP_HASH160 (convert public key to PKH) + * OP_DUP (duplicate PKH) + * OP_EQUAL (does PKH match refund PKH?) + * OP_IF + * OP_DROP (no need for duplicate PKH) + * + * OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is so script passes) + * OP_ELSE + * OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails) + * OP_HASH160 (hash secret) + * OP_EQUAL (do hashes of secrets match? if true, script passes else script fails) + * OP_ENDIF + */ + + private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes) + private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes) + private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes) + private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes) + private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF + + /** + * Returns redeemScript used for cross-chain trading. + *

+ * See comments in {@link BitcoinyHTLC} for more details. + * + * @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes + * @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund + * @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key + * @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds + */ + public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] hashOfSecret) { + return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)), + redeemScript3, redeemerPubKeyHash, redeemScript4, hashOfSecret, redeemScript5); + } + + /** + * Builds a custom transaction to spend HTLC P2SH. + * + * @param params blockchain network parameters + * @param amount output amount, should be total of input amounts, less miner fees + * @param spendKey key for signing transaction, and also where funds are 'sent' (output) + * @param fundingOutput output from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param lockTime (optional) transaction nLockTime, used in refund scenario + * @param scriptSigBuilder function for building scriptSig using transaction input signature + * @param outputPublicKeyHash PKH used to create P2PKH output + * @return Signed transaction for spending P2SH + */ + public static Transaction buildP2shTransaction(NetworkParameters params, Coin amount, ECKey spendKey, + List fundingOutputs, byte[] redeemScriptBytes, + Long lockTime, Function scriptSigBuilder, byte[] outputPublicKeyHash) { + Transaction transaction = new Transaction(params); + transaction.setVersion(2); + + // Output is back to P2SH funder + transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash)); + + for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { + TransactionOutput fundingOutput = fundingOutputs.get(inputIndex); + + // Input (without scriptSig prior to signing) + TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor()); + if (lockTime != null) + input.setSequenceNumber(LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF + else + input.setSequenceNumber(NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF + transaction.addInput(input); + } + + // Set locktime after inputs added but before input signatures are generated + if (lockTime != null) + transaction.setLockTime(lockTime); + + for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { + // Generate transaction signature for input + final boolean anyoneCanPay = false; + TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay); + + // Calculate transaction signature + byte[] txSigBytes = txSig.encodeToBitcoin(); + + // Build scriptSig using lambda and tx signature + Script scriptSig = scriptSigBuilder.apply(txSigBytes); + + // Set input scriptSig + transaction.getInput(inputIndex).setScriptSig(scriptSig); + } + + return transaction; + } + + /** + * Returns signed transaction claiming refund from HTLC P2SH. + * + * @param params blockchain network parameters + * @param refundAmount refund amount, should be total of input amounts, less miner fees + * @param refundKey key for signing transaction + * @param fundingOutputs outputs from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript + * @param receivingAccountInfo public-key-hash used for P2PKH output + * @return Signed transaction for refunding P2SH + */ + public static Transaction buildRefundTransaction(NetworkParameters params, Coin refundAmount, ECKey refundKey, + List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) { + Function refundSigScriptBuilder = (txSigBytes) -> { + // Build scriptSig with... + ScriptBuilder scriptBuilder = new ScriptBuilder(); + + // transaction signature + scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); + + // redeem public key + byte[] refundPubKey = refundKey.getPubKey(); + scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey)); + + // redeem script + scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); + + return scriptBuilder.build(); + }; + + // Send funds back to funding address + return buildP2shTransaction(params, refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo); + } + + /** + * Returns signed transaction redeeming funds from P2SH address. + * + * @param params blockchain network parameters + * @param redeemAmount redeem amount, should be total of input amounts, less miner fees + * @param redeemKey key for signing transaction + * @param fundingOutputs outputs from transaction that funded P2SH address + * @param redeemScriptBytes the redeemScript itself, in byte[] form + * @param secret actual 32-byte secret used when building redeemScript + * @param receivingAccountInfo Bitcoin PKH used for output + * @return Signed transaction for redeeming P2SH + */ + public static Transaction buildRedeemTransaction(NetworkParameters params, Coin redeemAmount, ECKey redeemKey, + List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) { + Function redeemSigScriptBuilder = (txSigBytes) -> { + // Build scriptSig with... + ScriptBuilder scriptBuilder = new ScriptBuilder(); + + // secret + scriptBuilder.addChunk(new ScriptChunk(secret.length, secret)); + + // transaction signature + scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes)); + + // redeem public key + byte[] redeemPubKey = redeemKey.getPubKey(); + scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey)); + + // redeem script + scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes)); + + return scriptBuilder.build(); + }; + + return buildP2shTransaction(params, redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo); + } + + /** + * Returns 'secret', if any, given HTLC's P2SH address. + *

+ * @throws ForeignBlockchainException + */ + public static byte[] findHtlcSecret(Bitcoiny bitcoiny, String p2shAddress) throws ForeignBlockchainException { + NetworkParameters params = bitcoiny.getNetworkParameters(); + String compoundKey = String.format("%s-%s-%d", params.getId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT); + + byte[] secret = SECRET_CACHE.getOrDefault(compoundKey, NO_SECRET_CACHE_ENTRY); + if (secret != NO_SECRET_CACHE_ENTRY) + return secret; + + List rawTransactions = bitcoiny.getAddressTransactions(p2shAddress); + + for (byte[] rawTransaction : rawTransactions) { + Transaction transaction = new Transaction(params, rawTransaction); + + // Cycle through inputs, looking for one that spends our HTLC + for (TransactionInput input : transaction.getInputs()) { + Script scriptSig = input.getScriptSig(); + List scriptChunks = scriptSig.getChunks(); + + // Expected number of script chunks for redeem. Refund might not have the same number. + int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/; + if (scriptChunks.size() != expectedChunkCount) + continue; + + // We're expecting last chunk to contain the actual redeemScript + ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1); + byte[] redeemScriptBytes = lastChunk.data; + + // If non-push scripts, redeemScript will be null + if (redeemScriptBytes == null) + continue; + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!inputAddress.toString().equals(p2shAddress)) + // Input isn't spending our HTLC + continue; + + secret = scriptChunks.get(0).data; + if (secret.length != BitcoinyHTLC.SECRET_LENGTH) + continue; + + // Cache secret for a while + SECRET_CACHE.put(compoundKey, secret); + + return secret; + } + } + + // Cache negative result + SECRET_CACHE.put(compoundKey, null); + + return null; + } + + /** + * Returns HTLC status, given P2SH address and expected redeem/refund amount + *

+ * @throws ForeignBlockchainException if error occurs + */ + public static Status determineHtlcStatus(BitcoinyBlockchainProvider blockchain, String p2shAddress, long minimumAmount) throws ForeignBlockchainException { + String compoundKey = String.format("%s-%s-%d", blockchain.getNetId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT); + + Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null); + if (cachedStatus != null) + return cachedStatus; + + byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress); + List transactionHashes = blockchain.getAddressTransactions(ourScriptPubKey, BitcoinyBlockchainProvider.INCLUDE_UNCONFIRMED); + + // Sort by confirmed first, followed by ascending height + transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight)); + + // Transaction cache + Map transactionsByHash = new HashMap<>(); + // HASH160(redeem script) for this p2shAddress + byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress); + + // Check for spends first, caching full transaction info as we progress just in case we don't return in this loop + for (TransactionHash transactionInfo : transactionHashes) { + BitcoinyTransaction bitcoinyTransaction = blockchain.getTransaction(transactionInfo.txHash); + + // Cache for possible later reuse + transactionsByHash.put(transactionInfo.txHash, bitcoinyTransaction); + + // Acceptable funding is one transaction output, so we're expecting only one input + if (bitcoinyTransaction.inputs.size() != 1) + // Wrong number of inputs + continue; + + String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig; + + List scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes()); + if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4) + // Not valid chunks for our form of HTLC + continue; + + // Last chunk is redeem script + byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash)) + // Not spending our specific HTLC redeem script + continue; + + if (scriptSigChunks.size() == 4) + // If we have 4 chunks, then secret is present, hence redeem + cachedStatus = transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED; + else + cachedStatus = transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED; + + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + + String ourScriptPubKeyHex = HashCode.fromBytes(ourScriptPubKey).toString(); + + // Check for funding + for (TransactionHash transactionInfo : transactionHashes) { + BitcoinyTransaction bitcoinyTransaction = transactionsByHash.get(transactionInfo.txHash); + if (bitcoinyTransaction == null) + // Should be present in map! + throw new ForeignBlockchainException("Cached Bitcoin transaction now missing?"); + + // Check outputs for our specific P2SH + for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) { + // Check amount + if (output.value < minimumAmount) + // Output amount too small (not taking fees into account) + continue; + + String scriptPubKeyHex = output.scriptPubKey; + if (!scriptPubKeyHex.equals(ourScriptPubKeyHex)) + // Not funding our specific P2SH + continue; + + cachedStatus = transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED; + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + } + + cachedStatus = Status.UNFUNDED; + STATUS_CACHE.put(compoundKey, cachedStatus); + return cachedStatus; + } + + private static List extractScriptSigChunks(byte[] scriptSigBytes) { + List chunks = new ArrayList<>(); + + int offset = 0; + int previousOffset = 0; + while (offset < scriptSigBytes.length) { + byte pushOp = scriptSigBytes[offset++]; + + if (pushOp < 0 || pushOp > 0x4c) + // Unacceptable OP + return Collections.emptyList(); + + // Special treatment for OP_PUSHDATA1 + if (pushOp == 0x4c) { + if (offset >= scriptSigBytes.length) + // Run out of scriptSig bytes? + return Collections.emptyList(); + + pushOp = scriptSigBytes[offset++]; + } + + previousOffset = offset; + offset += Byte.toUnsignedInt(pushOp); + + byte[] chunk = Arrays.copyOfRange(scriptSigBytes, previousOffset, offset); + chunks.add(chunk); + } + + return chunks; + } + + private static byte[] addressToScriptPubKey(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + byte[] scriptPubKey = new byte[1 + 1 + 20 + 1]; + scriptPubKey[0x00] = (byte) 0xa9; /* OP_HASH160 */ + scriptPubKey[0x01] = (byte) 0x14; /* PUSH 0x14 bytes */ + System.arraycopy(p2shAddressBytes, 1, scriptPubKey, 2, 0x14); + scriptPubKey[0x16] = (byte) 0x87; /* OP_EQUAL */ + + return scriptPubKey; + } + + private static byte[] addressToRedeemScriptHash(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + return Arrays.copyOfRange(p2shAddressBytes, 1, 1 + 20); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java new file mode 100644 index 00000000..caf0b36d --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java @@ -0,0 +1,146 @@ +package org.qortal.crosschain; + +import java.util.List; +import java.util.stream.Collectors; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; + +@XmlAccessorType(XmlAccessType.FIELD) +public class BitcoinyTransaction { + + public final String txHash; + + @XmlTransient + public final int size; + + @XmlTransient + public final int locktime; + + // Not present if transaction is unconfirmed + public final Integer timestamp; + + public static class Input { + @XmlTransient + public final String scriptSig; + + @XmlTransient + public final int sequence; + + public final String outputTxHash; + + public final int outputVout; + + // For JAXB + protected Input() { + this.scriptSig = null; + this.sequence = 0; + this.outputTxHash = null; + this.outputVout = 0; + } + + public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) { + this.scriptSig = scriptSig; + this.sequence = sequence; + this.outputTxHash = outputTxHash; + this.outputVout = outputVout; + } + + public String toString() { + return String.format("{output %s:%d, sequence %d, scriptSig %s}", + this.outputTxHash, this.outputVout, this.sequence, this.scriptSig); + } + } + @XmlTransient + public final List inputs; + + public static class Output { + @XmlTransient + public final String scriptPubKey; + + public final long value; + + public final List addresses; + + // For JAXB + protected Output() { + this.scriptPubKey = null; + this.value = 0; + this.addresses = null; + } + + public Output(String scriptPubKey, long value) { + this.scriptPubKey = scriptPubKey; + this.value = value; + this.addresses = null; + } + + public Output(String scriptPubKey, long value, List addresses) { + this.scriptPubKey = scriptPubKey; + this.value = value; + this.addresses = addresses; + } + + public String toString() { + return String.format("{value %d, scriptPubKey %s}", this.value, this.scriptPubKey); + } + } + public final List outputs; + + public final long totalAmount; + + // For JAXB + protected BitcoinyTransaction() { + this.txHash = null; + this.size = 0; + this.locktime = 0; + this.timestamp = 0; + this.inputs = null; + this.outputs = null; + this.totalAmount = 0; + } + + public BitcoinyTransaction(String txHash, int size, int locktime, Integer timestamp, + List inputs, List outputs) { + this.txHash = txHash; + this.size = size; + this.locktime = locktime; + this.timestamp = timestamp; + this.inputs = inputs; + this.outputs = outputs; + + this.totalAmount = outputs.stream().map(output -> output.value).reduce(0L, Long::sum); + } + + public String toString() { + return String.format("txHash %s, size %d, locktime %d, timestamp %d\n" + + "\tinputs: [%s]\n" + + "\toutputs: [%s]\n", + this.txHash, + this.size, + this.locktime, + this.timestamp, + this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")), + this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t"))); + } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof BitcoinyTransaction)) + return false; + + BitcoinyTransaction otherTransaction = (BitcoinyTransaction) other; + + return this.txHash.equals(otherTransaction.txHash); + } + + @Override + public int hashCode() { + return this.txHash.hashCode(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java new file mode 100644 index 00000000..b34aa199 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -0,0 +1,688 @@ +package org.qortal.crosschain; + +import java.io.IOException; +import java.math.BigDecimal; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.Scanner; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.net.ssl.SSLSocketFactory; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.json.simple.JSONArray; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.TrustlessSSLSocketFactory; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +/** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */ +public class ElectrumX extends BitcoinyBlockchainProvider { + + private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); + private static final Random RANDOM = new Random(); + + private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final int BLOCK_HEADER_LENGTH = 80; + + // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" + private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content + + /** Error message sent by some ElectrumX servers when they don't support returning verbose transactions. */ + private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; + + public static class Server { + String hostname; + + public enum ConnectionType { TCP, SSL } + ConnectionType connectionType; + + int port; + + public Server(String hostname, ConnectionType connectionType, int port) { + this.hostname = hostname; + this.connectionType = connectionType; + this.port = port; + } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof Server)) + return false; + + Server otherServer = (Server) other; + + return this.connectionType == otherServer.connectionType + && this.port == otherServer.port + && this.hostname.equals(otherServer.hostname); + } + + @Override + public int hashCode() { + return this.hostname.hashCode() ^ this.port; + } + + @Override + public String toString() { + return String.format("%s:%s:%d", this.connectionType.name(), this.hostname, this.port); + } + } + private Set servers = new HashSet<>(); + private List remainingServers = new ArrayList<>(); + private Set uselessServers = Collections.synchronizedSet(new HashSet<>()); + + private final String netId; + private final String expectedGenesisHash; + private final Map defaultPorts = new EnumMap<>(Server.ConnectionType.class); + + private final Object serverLock = new Object(); + private Server currentServer; + private Socket socket; + private Scanner scanner; + private int nextId = 1; + + private static final int TX_CACHE_SIZE = 200; + @SuppressWarnings("serial") + private final Map transactionCache = Collections.synchronizedMap(new LinkedHashMap<>(TX_CACHE_SIZE + 1, 0.75F, true) { + // This method is called just after a new entry has been added + @Override + public boolean removeEldestEntry(Map.Entry eldest) { + return size() > TX_CACHE_SIZE; + } + }); + + // Constructors + + public ElectrumX(String netId, String genesisHash, Collection initialServerList, Map defaultPorts) { + this.netId = netId; + this.expectedGenesisHash = genesisHash; + this.servers.addAll(initialServerList); + this.defaultPorts.putAll(defaultPorts); + } + + // Methods for use by other classes + + @Override + public String getNetId() { + return this.netId; + } + + /** + * Returns current blockchain height. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public int getCurrentHeight() throws ForeignBlockchainException { + Object blockObj = this.rpc("blockchain.headers.subscribe"); + if (!(blockObj instanceof JSONObject)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.headers.subscribe RPC"); + + JSONObject blockJson = (JSONObject) blockObj; + + Object heightObj = blockJson.get("height"); + + if (!(heightObj instanceof Long)) + throw new ForeignBlockchainException.NetworkException("Missing/invalid 'height' in JSON from ElectrumX blockchain.headers.subscribe RPC"); + + return ((Long) heightObj).intValue(); + } + + /** + * Returns list of raw block headers, starting from startHeight inclusive. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException { + Object blockObj = this.rpc("blockchain.block.headers", startHeight, count); + if (!(blockObj instanceof JSONObject)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.block.headers RPC"); + + JSONObject blockJson = (JSONObject) blockObj; + + Object countObj = blockJson.get("count"); + Object hexObj = blockJson.get("hex"); + + if (!(countObj instanceof Long) || !(hexObj instanceof String)) + throw new ForeignBlockchainException.NetworkException("Missing/invalid 'count' or 'hex' entries in JSON from ElectrumX blockchain.block.headers RPC"); + + Long returnedCount = (Long) countObj; + String hex = (String) hexObj; + + byte[] raw = HashCode.fromString(hex).asBytes(); + if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) + throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC"); + + List rawBlockHeaders = new ArrayList<>(returnedCount.intValue()); + for (int i = 0; i < returnedCount; ++i) + rawBlockHeaders.add(Arrays.copyOfRange(raw, i * BLOCK_HEADER_LENGTH, (i + 1) * BLOCK_HEADER_LENGTH)); + + return rawBlockHeaders; + } + + /** + * Returns confirmed balance, based on passed payment script. + *

+ * @return confirmed balance, or zero if script unknown + * @throws ForeignBlockchainException if there was an error + */ + @Override + public long getConfirmedBalance(byte[] script) throws ForeignBlockchainException { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); + if (!(balanceObj instanceof JSONObject)) + throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.scripthash.get_balance RPC"); + + JSONObject balanceJson = (JSONObject) balanceObj; + + Object confirmedBalanceObj = balanceJson.get("confirmed"); + + if (!(confirmedBalanceObj instanceof Long)) + throw new ForeignBlockchainException.NetworkException("Missing confirmed balance from ElectrumX blockchain.scripthash.get_balance RPC"); + + return (Long) balanceJson.get("confirmed"); + } + + /** + * Returns list of unspent outputs pertaining to passed payment script. + *

+ * @return list of unspent outputs, or empty list if script unknown + * @throws ForeignBlockchainException if there was an error. + */ + @Override + public List getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); + if (!(unspentJson instanceof JSONArray)) + throw new ForeignBlockchainException("Expected array output from ElectrumX blockchain.scripthash.listunspent RPC"); + + List unspentOutputs = new ArrayList<>(); + for (Object rawUnspent : (JSONArray) unspentJson) { + JSONObject unspent = (JSONObject) rawUnspent; + + int height = ((Long) unspent.get("height")).intValue(); + // We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0) + if (!includeUnconfirmed && height <= 0) + continue; + + byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); + int outputIndex = ((Long) unspent.get("tx_pos")).intValue(); + long value = (Long) unspent.get("value"); + + unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); + } + + return unspentOutputs; + } + + /** + * Returns raw transaction for passed transaction hash. + *

+ * NOTE: Do not mutate returned byte[]! + * + * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public byte[] getRawTransaction(String txHash) throws ForeignBlockchainException { + Object rawTransactionHex; + try { + rawTransactionHex = this.rpc("blockchain.transaction.get", txHash, false); + } catch (ForeignBlockchainException.NetworkException e) { + // DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'}) + if (Integer.valueOf(-5).equals(e.getDaemonErrorCode())) + throw new ForeignBlockchainException.NotFoundException(e.getMessage()); + + throw e; + } + + if (!(rawTransactionHex instanceof String)) + throw new ForeignBlockchainException.NetworkException("Expected hex string as raw transaction from ElectrumX blockchain.transaction.get RPC"); + + return HashCode.fromString((String) rawTransactionHex).asBytes(); + } + + /** + * Returns raw transaction for passed transaction hash. + *

+ * NOTE: Do not mutate returned byte[]! + * + * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException { + return getRawTransaction(HashCode.fromBytes(txHash).toString()); + } + + /** + * Returns transaction info for passed transaction hash. + *

+ * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs + */ + @Override + public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException { + // Check cache first + BitcoinyTransaction transaction = transactionCache.get(txHash); + if (transaction != null) + return transaction; + + Object transactionObj = null; + + do { + try { + transactionObj = this.rpc("blockchain.transaction.get", txHash, true); + } catch (ForeignBlockchainException.NetworkException e) { + // DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'}) + if (Integer.valueOf(-5).equals(e.getDaemonErrorCode())) + throw new ForeignBlockchainException.NotFoundException(e.getMessage()); + + // Some servers also return non-standard responses like this: + // {"error":"verbose transactions are currently unsupported","id":3,"jsonrpc":"2.0"} + // We should probably not use this server any more + if (e.getServer() != null && e.getMessage() != null && e.getMessage().contains(VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE)) { + Server uselessServer = (Server) e.getServer(); + LOGGER.trace(() -> String.format("Server %s doesn't support verbose transactions - barring use of that server", uselessServer)); + this.uselessServers.add(uselessServer); + this.closeServer(uselessServer); + continue; + } + + throw e; + } + } while (transactionObj == null); + + if (!(transactionObj instanceof JSONObject)) + throw new ForeignBlockchainException.NetworkException("Expected JSONObject as response from ElectrumX blockchain.transaction.get RPC"); + + JSONObject transactionJson = (JSONObject) transactionObj; + + Object inputsObj = transactionJson.get("vin"); + if (!(inputsObj instanceof JSONArray)) + throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vin' from ElectrumX blockchain.transaction.get RPC"); + + Object outputsObj = transactionJson.get("vout"); + if (!(outputsObj instanceof JSONArray)) + throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vout' from ElectrumX blockchain.transaction.get RPC"); + + try { + int size = ((Long) transactionJson.get("size")).intValue(); + int locktime = ((Long) transactionJson.get("locktime")).intValue(); + + // Timestamp might not be present, e.g. for unconfirmed transaction + Object timeObj = transactionJson.get("time"); + Integer timestamp = timeObj != null + ? ((Long) timeObj).intValue() + : null; + + List inputs = new ArrayList<>(); + for (Object inputObj : (JSONArray) inputsObj) { + JSONObject inputJson = (JSONObject) inputObj; + + String scriptSig = (String) ((JSONObject) inputJson.get("scriptSig")).get("hex"); + int sequence = ((Long) inputJson.get("sequence")).intValue(); + String outputTxHash = (String) inputJson.get("txid"); + int outputVout = ((Long) inputJson.get("vout")).intValue(); + + inputs.add(new BitcoinyTransaction.Input(scriptSig, sequence, outputTxHash, outputVout)); + } + + List outputs = new ArrayList<>(); + for (Object outputObj : (JSONArray) outputsObj) { + JSONObject outputJson = (JSONObject) outputObj; + + String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex"); + long value = BigDecimal.valueOf((Double) outputJson.get("value")).setScale(8).unscaledValue().longValue(); + + // address too, if present + List addresses = null; + Object addressesObj = ((JSONObject) outputJson.get("scriptPubKey")).get("addresses"); + if (addressesObj instanceof JSONArray) { + addresses = new ArrayList<>(); + for (Object addressObj : (JSONArray) addressesObj) + addresses.add((String) addressObj); + } + + outputs.add(new BitcoinyTransaction.Output(scriptPubKey, value, addresses)); + } + + transaction = new BitcoinyTransaction(txHash, size, locktime, timestamp, inputs, outputs); + + // Save into cache + transactionCache.put(txHash, transaction); + + return transaction; + } catch (NullPointerException | ClassCastException e) { + // Unexpected / invalid response from ElectrumX server + } + + throw new ForeignBlockchainException.NetworkException("Unexpected JSON format from ElectrumX blockchain.transaction.get RPC"); + } + + /** + * Returns list of transactions, relating to passed payment script. + *

+ * @return list of related transactions, or empty list if script unknown + * @throws ForeignBlockchainException if error occurs + */ + @Override + public List getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { + byte[] scriptHash = Crypto.digest(script); + Bytes.reverse(scriptHash); + + Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); + if (!(transactionsJson instanceof JSONArray)) + throw new ForeignBlockchainException.NetworkException("Expected array output from ElectrumX blockchain.scripthash.get_history RPC"); + + List transactionHashes = new ArrayList<>(); + + for (Object rawTransactionInfo : (JSONArray) transactionsJson) { + JSONObject transactionInfo = (JSONObject) rawTransactionInfo; + + Long height = (Long) transactionInfo.get("height"); + if (!includeUnconfirmed && (height == null || height == 0)) + // We only want confirmed transactions + continue; + + String txHash = (String) transactionInfo.get("tx_hash"); + + transactionHashes.add(new TransactionHash(height.intValue(), txHash)); + } + + return transactionHashes; + } + + /** + * Broadcasts raw transaction to network. + *

+ * @throws ForeignBlockchainException if error occurs + */ + @Override + public void broadcastTransaction(byte[] transactionBytes) throws ForeignBlockchainException { + Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString()); + + // We're expecting a simple string that is the transaction hash + if (!(rawBroadcastResult instanceof String)) + throw new ForeignBlockchainException.NetworkException("Unexpected response from ElectrumX blockchain.transaction.broadcast RPC"); + } + + // Class-private utility methods + + /** + * Query current server for its list of peer servers, and return those we can parse. + *

+ * @throws ForeignBlockchainException + * @throws ClassCastException to be handled by caller + */ + private Set serverPeersSubscribe() throws ForeignBlockchainException { + Set newServers = new HashSet<>(); + + Object peers = this.connectedRpc("server.peers.subscribe"); + + for (Object rawPeer : (JSONArray) peers) { + JSONArray peer = (JSONArray) rawPeer; + if (peer.size() < 3) + // We're expecting at least 3 fields for each peer entry: IP, hostname, features + continue; + + String hostname = (String) peer.get(1); + JSONArray features = (JSONArray) peer.get(2); + + for (Object rawFeature : features) { + String feature = (String) rawFeature; + Server.ConnectionType connectionType = null; + Integer port = null; + + switch (feature.charAt(0)) { + case 's': + connectionType = Server.ConnectionType.SSL; + port = this.defaultPorts.get(connectionType); + break; + + case 't': + connectionType = Server.ConnectionType.TCP; + port = this.defaultPorts.get(connectionType); + break; + + default: + // e.g. could be 'v' for protocol version, or 'p' for pruning limit + break; + } + + if (connectionType == null || port == null) + // We couldn't extract any peer connection info? + continue; + + // Possible non-default port? + if (feature.length() > 1) + try { + port = Integer.parseInt(feature.substring(1)); + } catch (NumberFormatException e) { + // no good + continue; // for-loop above + } + + Server newServer = new Server(hostname, connectionType, port); + newServers.add(newServer); + } + } + + return newServers; + } + + /** + * Performs RPC call, with automatic reconnection to different server if needed. + *

+ * @return "result" object from within JSON output + * @throws ForeignBlockchainException if server returns error or something goes wrong + */ + private Object rpc(String method, Object...params) throws ForeignBlockchainException { + synchronized (this.serverLock) { + if (this.remainingServers.isEmpty()) + this.remainingServers.addAll(this.servers); + + while (haveConnection()) { + Object response = connectedRpc(method, params); + if (response != null) + return response; + + // Didn't work, try another server... + this.closeServer(); + } + + // Failed to perform RPC - maybe lack of servers? + throw new ForeignBlockchainException.NetworkException(String.format("Failed to perform ElectrumX RPC %s", method)); + } + } + + /** Returns true if we have, or create, a connection to an ElectrumX server. */ + private boolean haveConnection() throws ForeignBlockchainException { + if (this.currentServer != null) + return true; + + while (!this.remainingServers.isEmpty()) { + Server server = this.remainingServers.remove(RANDOM.nextInt(this.remainingServers.size())); + LOGGER.trace(() -> String.format("Connecting to %s", server)); + + try { + SocketAddress endpoint = new InetSocketAddress(server.hostname, server.port); + int timeout = 5000; // ms + + this.socket = new Socket(); + this.socket.connect(endpoint, timeout); + this.socket.setTcpNoDelay(true); + + if (server.connectionType == Server.ConnectionType.SSL) { + SSLSocketFactory factory = TrustlessSSLSocketFactory.getSocketFactory(); + this.socket = factory.createSocket(this.socket, server.hostname, server.port, true); + } + + this.scanner = new Scanner(this.socket.getInputStream()); + this.scanner.useDelimiter("\n"); + + // Check connection is suitable by asking for server features, including genesis block hash + JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); + + if (featuresJson == null || Double.valueOf((String) featuresJson.get("protocol_min")) < MIN_PROTOCOL_VERSION) + continue; + + if (this.expectedGenesisHash != null && !((String) featuresJson.get("genesis_hash")).equals(this.expectedGenesisHash)) + continue; + + // Ask for more servers + Set moreServers = serverPeersSubscribe(); + // Discard duplicate servers we already know + moreServers.removeAll(this.servers); + // Add to both lists + this.remainingServers.addAll(moreServers); + this.servers.addAll(moreServers); + + LOGGER.debug(() -> String.format("Connected to %s", server)); + this.currentServer = server; + return true; + } catch (IOException | ForeignBlockchainException | ClassCastException | NullPointerException e) { + // Didn't work, try another server... + closeServer(); + } + } + + return false; + } + + /** + * Perform RPC using currently connected server. + *

+ * @param method + * @param params + * @return response Object, or null if server fails to respond + * @throws ForeignBlockchainException if server returns error + */ + @SuppressWarnings("unchecked") + private Object connectedRpc(String method, Object...params) throws ForeignBlockchainException { + JSONObject requestJson = new JSONObject(); + requestJson.put("id", this.nextId++); + requestJson.put("method", method); + requestJson.put("jsonrpc", "2.0"); + + JSONArray requestParams = new JSONArray(); + requestParams.addAll(Arrays.asList(params)); + requestJson.put("params", requestParams); + + String request = requestJson.toJSONString() + "\n"; + LOGGER.trace(() -> String.format("Request: %s", request)); + + final String response; + + try { + this.socket.getOutputStream().write(request.getBytes()); + response = scanner.next(); + } catch (IOException | NoSuchElementException e) { + // Unable to send, or receive -- try another server? + return null; + } + + LOGGER.trace(() -> String.format("Response: %s", response)); + + if (response.isEmpty()) + // Empty response - try another server? + return null; + + Object responseObj = JSONValue.parse(response); + if (!(responseObj instanceof JSONObject)) + // Unexpected response - try another server? + return null; + + JSONObject responseJson = (JSONObject) responseObj; + + Object errorObj = responseJson.get("error"); + if (errorObj != null) { + if (errorObj instanceof String) + throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer); + + if (!(errorObj instanceof JSONObject)) + throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer); + + JSONObject errorJson = (JSONObject) errorObj; + + Object messageObj = errorJson.get("message"); + + if (!(messageObj instanceof String)) + throw new ForeignBlockchainException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer); + + String message = (String) messageObj; + + // Some error 'messages' are actually wrapped upstream bitcoind errors: + // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" + // We want to detect these and extract the upstream error code for caller's use + Matcher messageMatcher = DAEMON_ERROR_REGEX.matcher(message); + if (messageMatcher.find()) + try { + int daemonErrorCode = Integer.parseInt(messageMatcher.group(1)); + throw new ForeignBlockchainException.NetworkException(daemonErrorCode, message, this.currentServer); + } catch (NumberFormatException e) { + // We couldn't parse the error code integer? Fall-through to generic exception... + } + + throw new ForeignBlockchainException.NetworkException(message, this.currentServer); + } + + return responseJson.get("result"); + } + + /** + * Closes connection to server if it is currently connected server. + * @param server + */ + private void closeServer(Server server) { + synchronized (this.serverLock) { + if (this.currentServer == null || !this.currentServer.equals(server)) + return; + + if (this.socket != null && !this.socket.isClosed()) + try { + this.socket.close(); + } catch (IOException e) { + // We did try... + } + + this.socket = null; + this.scanner = null; + this.currentServer = null; + } + } + + /** Closes connection to currently connected server (if any). */ + private void closeServer() { + synchronized (this.serverLock) { + this.closeServer(this.currentServer); + } + } + +} diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java new file mode 100644 index 00000000..0a71e9d9 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -0,0 +1,9 @@ +package org.qortal.crosschain; + +public interface ForeignBlockchain { + + public boolean isValidAddress(String address); + + public boolean isValidWalletKey(String walletKey); + +} diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java b/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java new file mode 100644 index 00000000..1e658621 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java @@ -0,0 +1,77 @@ +package org.qortal.crosschain; + +@SuppressWarnings("serial") +public class ForeignBlockchainException extends Exception { + + public ForeignBlockchainException() { + super(); + } + + public ForeignBlockchainException(String message) { + super(message); + } + + public static class NetworkException extends ForeignBlockchainException { + private final Integer daemonErrorCode; + private final transient Object server; + + public NetworkException() { + super(); + this.daemonErrorCode = null; + this.server = null; + } + + public NetworkException(String message) { + super(message); + this.daemonErrorCode = null; + this.server = null; + } + + public NetworkException(int errorCode, String message) { + super(message); + this.daemonErrorCode = errorCode; + this.server = null; + } + + public NetworkException(String message, Object server) { + super(message); + this.daemonErrorCode = null; + this.server = server; + } + + public NetworkException(int errorCode, String message, Object server) { + super(message); + this.daemonErrorCode = errorCode; + this.server = server; + } + + public Integer getDaemonErrorCode() { + return this.daemonErrorCode; + } + + public Object getServer() { + return this.server; + } + } + + public static class NotFoundException extends ForeignBlockchainException { + public NotFoundException() { + super(); + } + + public NotFoundException(String message) { + super(message); + } + } + + public static class InsufficientFundsException extends ForeignBlockchainException { + public InsufficientFundsException() { + super(); + } + + public InsufficientFundsException(String message) { + super(message); + } + } + +} diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java new file mode 100644 index 00000000..5cbe4044 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -0,0 +1,175 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; + +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.Context; +import org.bitcoinj.core.NetworkParameters; +import org.libdohj.params.LitecoinMainNetParams; +import org.libdohj.params.LitecoinRegTestParams; +import org.libdohj.params.LitecoinTestNet3Params; +import org.qortal.crosschain.ElectrumX.Server; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.settings.Settings; + +public class Litecoin extends Bitcoiny { + + public static final String CURRENCY_CODE = "LTC"; + + private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 LTC per 1000 bytes + + // Temporary values until a dynamic fee system is written. + private static final long MAINNET_FEE = 1000L; + private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST + + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + public enum LitecoinNet { + MAIN { + @Override + public NetworkParameters getParams() { + return LitecoinMainNetParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + // Servers chosen on NO BASIS WHATSOEVER from various sources! + new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002), + new Server("backup.electrum-ltc.org", Server.ConnectionType.TCP, 50001), + new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 50001), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002), + new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002), + new Server("ltc.rentonisk.com", Server.ConnectionType.TCP, 50001), + new Server("ltc.rentonisk.com", Server.ConnectionType.SSL, 50002), + new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002), + new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022)); + } + + @Override + public String getGenesisHash() { + return "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2"; + } + + @Override + public long getP2shFee(Long timestamp) { + // TODO: This will need to be replaced with something better in the near future! + return MAINNET_FEE; + } + }, + TEST3 { + @Override + public NetworkParameters getParams() { + return LitecoinTestNet3Params.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("electrum-ltc.bysh.me", Server.ConnectionType.TCP, 51001), + new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 51002), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 51001), + new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 51002)); + } + + @Override + public String getGenesisHash() { + return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0"; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }, + REGTEST { + @Override + public NetworkParameters getParams() { + return LitecoinRegTestParams.get(); + } + + @Override + public Collection getServers() { + return Arrays.asList( + new Server("localhost", Server.ConnectionType.TCP, 50001), + new Server("localhost", Server.ConnectionType.SSL, 50002)); + } + + @Override + public String getGenesisHash() { + // This is unique to each regtest instance + return null; + } + + @Override + public long getP2shFee(Long timestamp) { + return NON_MAINNET_FEE; + } + }; + + public abstract NetworkParameters getParams(); + public abstract Collection getServers(); + public abstract String getGenesisHash(); + public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException; + } + + private static Litecoin instance; + + private final LitecoinNet litecoinNet; + + // Constructors and instance + + private Litecoin(LitecoinNet litecoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) { + super(blockchain, bitcoinjContext, currencyCode); + this.litecoinNet = litecoinNet; + + LOGGER.info(() -> String.format("Starting Litecoin support using %s", this.litecoinNet.name())); + } + + public static synchronized Litecoin getInstance() { + if (instance == null) { + LitecoinNet litecoinNet = Settings.getInstance().getLitecoinNet(); + + BitcoinyBlockchainProvider electrumX = new ElectrumX("Litecoin-" + litecoinNet.name(), litecoinNet.getGenesisHash(), litecoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS); + Context bitcoinjContext = new Context(litecoinNet.getParams()); + + instance = new Litecoin(litecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + } + + return instance; + } + + // Getters & setters + + public static synchronized void resetForTesting() { + instance = null; + } + + // Actual useful methods for use by other classes + + /** Default Litecoin fee is lower than Bitcoin: only 10sats/byte. */ + @Override + public Coin getFeePerKb() { + return DEFAULT_FEE_PER_KB; + } + + /** + * Returns estimated LTC fee, in sats per 1000bytes, optionally for historic timestamp. + * + * @param timestamp optional milliseconds since epoch, or null for 'now' + * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong + */ + @Override + public long getP2shFee(Long timestamp) throws ForeignBlockchainException { + return this.litecoinNet.getP2shFee(timestamp); + } + +} diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java new file mode 100644 index 00000000..454e80c2 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -0,0 +1,853 @@ +package org.qortal.crosschain; + +import static org.ciyam.at.OpCode.calcOffset; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.API; +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.asset.Asset; +import org.qortal.at.QortalFunctionCode; +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.MessageTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +/** + * Cross-chain trade AT + * + *

+ *

    + *
  • Bob generates Litecoin & Qortal 'trade' keys + *
      + *
    • private key required to sign P2SH redeem tx
    • + *
    • private key could 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 deploys Qortal AT + *
      + *
    + *
  • + *
  • Alice finds Qortal AT and wants to trade + *
      + *
    • Alice generates Litecoin & Qortal 'trade' keys
    • + *
    • Alice funds Litecoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Litecoin PKH
      • + *
      + *
    • + *
    + *
  • + *
  • Bob receives "offer" MESSAGE + *
      + *
    • Checks Alice's P2SH-A
    • + *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: + *
        + *
      • Alice's trade Qortal address
      • + *
      • Alice's trade Litecoin PKH
      • + *
      • hash-of-secret-A
      • + *
      + *
    • + *
    + *
  • + *
  • Alice checks Qortal AT to confirm it's locked to her + *
      + *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
        + *
      • secret-A
      • + *
      • Qortal receiving address of her chosing
      • + *
      + *
    • + *
    • AT's QORT funds are sent to Qortal receiving address
    • + *
    + *
  • + *
  • Bob checks AT, extracts secret-A + *
      + *
    • Bob redeems P2SH-A using his Litecoin trade key and secret-A
    • + *
    • P2SH-A LTC funds end up at Litecoin address determined by redeem transaction output(s)
    • + *
    + *
  • + *
+ */ +public class LitecoinACCTv1 implements ACCT { + + public static final String NAME = LitecoinACCTv1.class.getSimpleName(); + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes + + public static final int SECRET_LENGTH = 32; + + /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ + private static final int MODE_VALUE_OFFSET = 61; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); + + public static class OfferMessageData { + public byte[] partnerLitecoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerLitecoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; + public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ + + 24 /*partner's Litecoin PKH (padded from 20 to 24)*/ + + 8 /*AT trade timeout (minutes)*/ + + 24 /*hash of secret-A (padded from 20 to 24)*/ + + 8 /*lockTimeA*/; + public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; + public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + + private static LitecoinACCTv1 instance; + + private LitecoinACCTv1() { + } + + public static synchronized LitecoinACCTv1 getInstance() { + if (instance == null) + instance = new LitecoinACCTv1(); + + return instance; + } + + @Override + public byte[] getCodeBytesHash() { + return CODE_BYTES_HASH; + } + + @Override + public int getModeByteOffset() { + return MODE_BYTE_OFFSET; + } + + @Override + public ForeignBlockchain getBlockchain() { + return Litecoin.getInstance(); + } + + /** + * Returns Qortal AT creation bytes for cross-chain trading AT. + *

+ * tradeTimeout (minutes) is the time window for the trade partner to send the + * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. + * + * @param creatorTradeAddress AT creator's trade Qortal address + * @param litecoinPublicKeyHash 20-byte HASH160 of creator's trade Litecoin public key + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT + * @param litecoinAmount how much LTC the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade + */ + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] litecoinPublicKeyHash, long qortAmount, long litecoinAmount, int tradeTimeout) { + if (litecoinPublicKeyHash.length != 20) + throw new IllegalArgumentException("Litecoin public key hash should be 20 bytes"); + + // Labels for data segment addresses + int addrCounter = 0; + + // Constants (with corresponding dataByteBuffer.put*() calls below) + + final int addrCreatorTradeAddress1 = addrCounter++; + final int addrCreatorTradeAddress2 = addrCounter++; + final int addrCreatorTradeAddress3 = addrCounter++; + final int addrCreatorTradeAddress4 = addrCounter++; + + final int addrLitecoinPublicKeyHash = addrCounter; + addrCounter += 4; + + final int addrQortAmount = addrCounter++; + final int addrLitecoinAmount = addrCounter++; + final int addrTradeTimeout = addrCounter++; + + final int addrMessageTxnType = addrCounter++; + final int addrExpectedTradeMessageLength = addrCounter++; + final int addrExpectedRedeemMessageLength = addrCounter++; + + final int addrCreatorAddressPointer = addrCounter++; + final int addrQortalPartnerAddressPointer = addrCounter++; + final int addrMessageSenderPointer = addrCounter++; + + final int addrTradeMessagePartnerLitecoinPKHOffset = addrCounter++; + final int addrPartnerLitecoinPKHPointer = addrCounter++; + final int addrTradeMessageHashOfSecretAOffset = addrCounter++; + final int addrHashOfSecretAPointer = addrCounter++; + + final int addrRedeemMessageReceivingAddressOffset = addrCounter++; + + final int addrMessageDataPointer = addrCounter++; + final int addrMessageDataLength = addrCounter++; + + final int addrPartnerReceivingAddressPointer = addrCounter++; + + final int addrEndOfConstants = addrCounter; + + // Variables + + final int addrCreatorAddress1 = addrCounter++; + final int addrCreatorAddress2 = addrCounter++; + final int addrCreatorAddress3 = addrCounter++; + final int addrCreatorAddress4 = addrCounter++; + + final int addrQortalPartnerAddress1 = addrCounter++; + final int addrQortalPartnerAddress2 = addrCounter++; + final int addrQortalPartnerAddress3 = addrCounter++; + final int addrQortalPartnerAddress4 = addrCounter++; + + final int addrLockTimeA = addrCounter++; + final int addrRefundTimeout = addrCounter++; + final int addrRefundTimestamp = addrCounter++; + final int addrLastTxnTimestamp = addrCounter++; + final int addrBlockTimestamp = addrCounter++; + final int addrTxnType = addrCounter++; + final int addrResult = addrCounter++; + + final int addrMessageSender1 = addrCounter++; + final int addrMessageSender2 = addrCounter++; + final int addrMessageSender3 = addrCounter++; + final int addrMessageSender4 = addrCounter++; + + final int addrMessageLength = addrCounter++; + + final int addrMessageData = addrCounter; + addrCounter += 4; + + final int addrHashOfSecretA = addrCounter; + addrCounter += 4; + + final int addrPartnerLitecoinPKH = addrCounter; + addrCounter += 4; + + final int addrPartnerReceivingAddress = addrCounter; + addrCounter += 4; + + final int addrMode = addrCounter++; + assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET); + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // AT creator's trade Qortal address, decoded from Base58 + assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; + byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); + dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); + + // Litecoin public key hash + assert dataByteBuffer.position() == addrLitecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrLitecoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(litecoinPublicKeyHash, 32, 0)); + + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); + + // Expected Litecoin amount + assert dataByteBuffer.position() == addrLitecoinAmount * MachineState.VALUE_SIZE : "addrLitecoinAmount incorrect"; + dataByteBuffer.putLong(litecoinAmount); + + // Suggested trade timeout (minutes) + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); + + // We're only interested in MESSAGE transactions + assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; + dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); + + // Expected length of 'trade' MESSAGE data from AT creator + assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; + dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); + + // Expected length of 'redeem' MESSAGE data from trade partner + assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; + dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); + + // Index into data segment of AT creator's address, used by GET_B_IND + assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; + dataByteBuffer.putLong(addrCreatorAddress1); + + // Index into data segment of partner's Qortal address, used by SET_B_IND + assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; + dataByteBuffer.putLong(addrQortalPartnerAddress1); + + // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND + assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; + dataByteBuffer.putLong(addrMessageSender1); + + // Offset into 'trade' MESSAGE data payload for extracting partner's Litecoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerLitecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerLitecoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of partner's Litecoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerLitecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerLitecoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerLitecoinPKH); + + // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Index into data segment to hash of secret A, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretA); + + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address + assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Source location and length for hashing any passed secret + assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; + dataByteBuffer.putLong(addrMessageData); + assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; + dataByteBuffer.putLong(32L); + + // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; + dataByteBuffer.putLong(addrPartnerReceivingAddress); + + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; + + // Code labels + Integer labelRefund = null; + + Integer labelTradeTxnLoop = null; + Integer labelCheckTradeTxn = null; + Integer labelCheckCancelTxn = null; + Integer labelNotTradeNorCancelTxn = null; + Integer labelCheckNonRefundTradeTxn = null; + Integer labelTradeTxnExtract = null; + Integer labelRedeemTxnLoop = null; + Integer labelCheckRedeemTxn = null; + Integer labelCheckRedeemTxnSender = null; + Integer labelPayout = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* 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, addrLastTxnTimestamp)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ + + /* Transaction processing loop */ + labelTradeTxnLoop = codeByteBuffer.position(); + + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrResult 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, labelCheckTradeTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTradeTxn = 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, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + + /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ + + // 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 message sender's address with AT creator's trade address. If they don't match, check for cancel situation. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + // Message sender's address matches AT creator's trade address so go process 'trade' message + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); + + /* Checking message sender for possible cancel message */ + labelCheckCancelTxn = codeByteBuffer.position(); + + // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + // Partner address is AT creator's address, so cancel offer and finish. + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + + /* Not trade nor cancel message */ + labelNotTradeNorCancelTxn = codeByteBuffer.position(); + + // Loop to find another transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Possible switch-to-trade-mode message */ + labelCheckNonRefundTradeTxn = codeByteBuffer.position(); + + // Check 'trade' message we received has expected number of message bytes + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to info extraction code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); + // Message length didn't match - go back to finding another 'trade' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + + /* Extracting info from 'trade' MESSAGE transaction */ + labelTradeTxnExtract = codeByteBuffer.position(); + + // 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 addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); + + // Extract trade partner's Litecoin public key hash (PKH) from message into B + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerLitecoinPKHOffset)); + // Store partner's Litecoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerLitecoinPKHPointer)); + // Extract AT trade timeout (minutes) (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout)); + + // Grab next 32 bytes + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); + + // Extract hash-of-secret-A (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + // Extract lockTime-A (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); + + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); + + /* We are in 'trade mode' */ + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ + + // 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, labelRedeemTxnLoop))); + // 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 */ + labelRedeemTxnLoop = 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, addrLastTxnTimestamp)); + // 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, labelCheckRedeemTxn))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckRedeemTxn = 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, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); + // If transaction type is not MESSAGE type then go look for another transaction + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check message payload length */ + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + // If message length matches, branch to sender checking code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); + // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Check transaction's sender */ + labelCheckRedeemTxnSender = codeByteBuffer.position(); + + // 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, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + + /* Check 'secret-A' in transaction's message */ + + // Extract secret-A from first 32 bytes of 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 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 addrHashOfSecretAPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); + // 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.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); + + /* Success! Pay arranged amount to receiving address */ + labelPayout = codeByteBuffer.position(); + + // Extract Qortal receiving address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); + // Pay AT's balance to receiving address + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); + // Set redeemed mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + 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(); + + // Set refunded mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile LTC-QORT ACCT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + assert Arrays.equals(Crypto.digest(codeBytes), LitecoinACCTv1.CODE_BYTES_HASH) + : String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes))); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { + ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + @Override + public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + */ + public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException { + byte[] addressBytes = new byte[25]; // for general use + String atAddress = atStateData.getATAddress(); + + CrossChainTradeData tradeData = new CrossChainTradeData(); + + tradeData.foreignBlockchain = SupportedBlockchain.LITECOIN.name(); + tradeData.acctName = NAME; + + tradeData.qortalAtAddress = atAddress; + tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); + tradeData.creationTimestamp = creationTimestamp; + + Account atAccount = new Account(repository, atAddress); + tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); + + byte[] stateData = atStateData.getStateData(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); + dataByteBuffer.position(MachineState.HEADER_LENGTH); + + /* Constants */ + + // Skip creator's trade address + dataByteBuffer.get(addressBytes); + tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Creator's Litecoin/foreign public key hash + tradeData.creatorForeignPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorForeignPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes + + // We don't use secret-B + tradeData.hashOfSecretB = null; + + // Redeem payout + tradeData.qortAmount = dataByteBuffer.getLong(); + + // Expected LTC amount + tradeData.expectedForeignAmount = dataByteBuffer.getLong(); + + // Trade timeout + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + + // Skip MESSAGE transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'trade' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected 'redeem' message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Qortal trade address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message sender + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for partner's Litecoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's Litecoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'trade' message data offset for hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for partner's Qortal receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to message data + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip message data length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to partner's receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + /* End of constants / begin variables */ + + // Skip AT creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Partner's trade address (if present) + dataByteBuffer.get(addressBytes); + String qortalRecipient = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + + // Potential lockTimeA (if in trade mode) + int lockTimeA = (int) dataByteBuffer.getLong(); + + // AT refund timeout (probably only useful for debugging) + int refundTimeout = (int) dataByteBuffer.getLong(); + + // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) + long tradeRefundTimestamp = dataByteBuffer.getLong(); + + // Skip last transaction timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip block timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary result + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message sender + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Skip message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message data + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // 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 partner's Litecoin PKH + byte[] partnerLitecoinPKH = new byte[20]; + dataByteBuffer.get(partnerLitecoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerLitecoinPKH.length); // skip to 32 bytes + + // Partner's receiving address (if present) + byte[] partnerReceivingAddress = new byte[25]; + dataByteBuffer.get(partnerReceivingAddress); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes + + // Trade AT's 'mode' + long modeValue = dataByteBuffer.getLong(); + AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL)); + + /* End of variables */ + + if (mode != null && mode != AcctMode.OFFERING) { + tradeData.mode = mode; + tradeData.refundTimeout = refundTimeout; + tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; + tradeData.qortalPartnerAddress = qortalRecipient; + tradeData.hashOfSecretA = hashOfSecretA; + tradeData.partnerForeignPKH = partnerLitecoinPKH; + tradeData.lockTimeA = lockTimeA; + + if (mode == AcctMode.REDEEMED) + tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); + } else { + tradeData.mode = AcctMode.OFFERING; + } + + tradeData.duplicateDeprecated(); + + return tradeData; + } + + /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); + } + + /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ + public static OfferMessageData extractOfferMessageData(byte[] messageData) { + if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) + return null; + + OfferMessageData offerMessageData = new OfferMessageData(); + offerMessageData.partnerLitecoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); + offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); + + return offerMessageData; + } + + /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) { + byte[] data = new byte[TRADE_MESSAGE_LENGTH]; + byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout); + + System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); + System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); + System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); + + return data; + } + + /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ + @Override + public byte[] buildCancelMessage(String creatorQortalAddress) { + byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); + + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); + + return data; + } + + /** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */ + public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) { + byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; + byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length); + + return data; + } + + /** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ + public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) { + // refund should be triggered halfway between offerMessageTimestamp and lockTimeA + return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L); + } + + public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalPartnerAddress; + + // We don't have partner's public key so we check every message to AT + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find 'redeem' message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != REDEEM_MESSAGE_LENGTH) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract secretA + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + return secretA; + } + + return null; + } + +} diff --git a/src/main/java/org/qortal/crosschain/SimpleTransaction.java b/src/main/java/org/qortal/crosschain/SimpleTransaction.java new file mode 100644 index 00000000..0fae20a5 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/SimpleTransaction.java @@ -0,0 +1,32 @@ +package org.qortal.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class SimpleTransaction { + private String txHash; + private Integer timestamp; + private long totalAmount; + + public SimpleTransaction() { + } + + public SimpleTransaction(String txHash, Integer timestamp, long totalAmount) { + this.txHash = txHash; + this.timestamp = timestamp; + this.totalAmount = totalAmount; + } + + public String getTxHash() { + return txHash; + } + + public Integer getTimestamp() { + return timestamp; + } + + public long getTotalAmount() { + return totalAmount; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java new file mode 100644 index 00000000..7b6f91f5 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -0,0 +1,113 @@ +package org.qortal.crosschain; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.qortal.utils.ByteArray; +import org.qortal.utils.Triple; + +public enum SupportedBlockchain { + + BITCOIN(Arrays.asList( + Triple.valueOf(BitcoinACCTv1.NAME, BitcoinACCTv1.CODE_BYTES_HASH, BitcoinACCTv1::getInstance) + // Could add improved BitcoinACCTv2 here in the future + )) { + @Override + public ForeignBlockchain getInstance() { + return Bitcoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return BitcoinACCTv1.getInstance(); + } + }, + + LITECOIN(Arrays.asList( + Triple.valueOf(LitecoinACCTv1.NAME, LitecoinACCTv1.CODE_BYTES_HASH, LitecoinACCTv1::getInstance) + )) { + @Override + public ForeignBlockchain getInstance() { + return Litecoin.getInstance(); + } + + @Override + public ACCT getLatestAcct() { + return LitecoinACCTv1.getInstance(); + } + }; + + private static final Map> supportedAcctsByCodeHash = Arrays.stream(SupportedBlockchain.values()) + .map(supportedBlockchain -> supportedBlockchain.supportedAccts) + .flatMap(List::stream) + .collect(Collectors.toUnmodifiableMap(triple -> new ByteArray(triple.getB()), Triple::getC)); + + private static final Map> supportedAcctsByName = Arrays.stream(SupportedBlockchain.values()) + .map(supportedBlockchain -> supportedBlockchain.supportedAccts) + .flatMap(List::stream) + .collect(Collectors.toUnmodifiableMap(Triple::getA, Triple::getC)); + + private static final Map blockchainsByName = Arrays.stream(SupportedBlockchain.values()) + .collect(Collectors.toUnmodifiableMap(Enum::name, blockchain -> blockchain)); + + private final List>> supportedAccts; + + SupportedBlockchain(List>> supportedAccts) { + this.supportedAccts = supportedAccts; + } + + public abstract ForeignBlockchain getInstance(); + public abstract ACCT getLatestAcct(); + + public static Map> getAcctMap() { + return supportedAcctsByCodeHash; + } + + public static SupportedBlockchain fromString(String name) { + return blockchainsByName.get(name); + } + + public static Map> getFilteredAcctMap(SupportedBlockchain blockchain) { + if (blockchain == null) + return getAcctMap(); + + return blockchain.supportedAccts.stream() + .collect(Collectors.toUnmodifiableMap(triple -> new ByteArray(triple.getB()), Triple::getC)); + } + + public static Map> getFilteredAcctMap(String specificBlockchain) { + if (specificBlockchain == null) + return getAcctMap(); + + SupportedBlockchain blockchain = blockchainsByName.get(specificBlockchain); + if (blockchain == null) + return Collections.emptyMap(); + + return getFilteredAcctMap(blockchain); + } + + public static ACCT getAcctByCodeHash(byte[] codeHash) { + ByteArray wrappedCodeHash = new ByteArray(codeHash); + + Supplier acctInstanceSupplier = supportedAcctsByCodeHash.get(wrappedCodeHash); + + if (acctInstanceSupplier == null) + return null; + + return acctInstanceSupplier.get(); + } + + public static ACCT getAcctByName(String acctName) { + Supplier acctInstanceSupplier = supportedAcctsByName.get(acctName); + + if (acctInstanceSupplier == null) + return null; + + return acctInstanceSupplier.get(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/TransactionHash.java b/src/main/java/org/qortal/crosschain/TransactionHash.java new file mode 100644 index 00000000..c002ae80 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/TransactionHash.java @@ -0,0 +1,31 @@ +package org.qortal.crosschain; + +import java.util.Comparator; + +public class TransactionHash { + + public static final Comparator CONFIRMED_FIRST = (a, b) -> Boolean.compare(a.height != 0, b.height != 0); + + public final int height; + public final String txHash; + + public TransactionHash(int height, String txHash) { + this.height = height; + this.txHash = txHash; + } + + public int getHeight() { + return this.height; + } + + public String getTxHash() { + return this.txHash; + } + + public String toString() { + return this.height == 0 + ? String.format("txHash %s (unconfirmed)", this.txHash) + : String.format("txHash %s (height %d)", this.txHash, this.height); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/UnspentOutput.java b/src/main/java/org/qortal/crosschain/UnspentOutput.java new file mode 100644 index 00000000..86aa533d --- /dev/null +++ b/src/main/java/org/qortal/crosschain/UnspentOutput.java @@ -0,0 +1,16 @@ +package org.qortal.crosschain; + +/** Unspent output info as returned by ElectrumX network. */ +public class UnspentOutput { + public final byte[] hash; + public final int index; + public final int height; + public final long value; + + public UnspentOutput(byte[] hash, int index, int height, long value) { + this.hash = hash; + this.index = index; + this.height = height; + this.value = value; + } +} \ No newline at end of file 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..69250e54 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -0,0 +1,109 @@ +package org.qortal.data.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import org.qortal.crosschain.AcctMode; + +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 qortalAtAddress; + + @Schema(description = "AT creator's Qortal address") + public String qortalCreator; + + @Schema(description = "AT creator's ephemeral trading key-pair represented as Qortal address") + public String qortalCreatorTradeAddress; + + @Deprecated + @Schema(description = "DEPRECATED: use creatorForeignPKH instead") + public byte[] creatorBitcoinPKH; + + @Schema(description = "AT creator's foreign blockchain trade public-key-hash (PKH)") + public byte[] creatorForeignPKH; + + @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") + public long creationTimestamp; + + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + public int tradeTimeout; + + @Schema(description = "AT's current QORT balance") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long qortBalance; + + @Schema(description = "HASH160 of 32-byte secret-A") + public byte[] hashOfSecretA; + + @Schema(description = "HASH160 of 32-byte secret-B") + public byte[] hashOfSecretB; + + @Schema(description = "Final QORT payment that will be sent to Qortal trade partner") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long qortAmount; + + @Schema(description = "Trade partner's Qortal address (trade begins when this is set)") + public String qortalPartnerAddress; + + @Schema(description = "Timestamp when AT switched to trade mode") + public Long tradeModeTimestamp; + + @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") + public Integer refundTimeout; + + @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") + public Integer tradeRefundHeight; + + @Deprecated + @Schema(description = "DEPRECATED: use expectedForeignAmount instread") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long expectedBitcoin; + + @Schema(description = "Amount, in foreign blockchain currency, that AT creator expects trade partner to pay out (excluding miner fees)") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + public long expectedForeignAmount; + + @Schema(description = "Current AT execution mode") + public AcctMode mode; + + @Schema(description = "Suggested P2SH-A nLockTime based on trade timeout") + public Integer lockTimeA; + + @Schema(description = "Suggested P2SH-B nLockTime based on trade timeout") + public Integer lockTimeB; + + @Deprecated + @Schema(description = "DEPRECATED: use partnerForeignPKH instead") + public byte[] partnerBitcoinPKH; + + @Schema(description = "Trade partner's foreign blockchain public-key-hash (PKH)") + public byte[] partnerForeignPKH; + + @Schema(description = "Trade partner's Qortal receiving address") + public String qortalPartnerReceivingAddress; + + public String foreignBlockchain; + + public String acctName; + + // Constructors + + // Necessary for JAXB + public CrossChainTradeData() { + } + + public void duplicateDeprecated() { + this.creatorBitcoinPKH = this.creatorForeignPKH; + this.expectedBitcoin = this.expectedForeignAmount; + this.partnerBitcoinPKH = this.partnerForeignPKH; + } + +} diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java new file mode 100644 index 00000000..19481466 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -0,0 +1,268 @@ +package org.qortal.data.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlTransient; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.json.JSONObject; + +import org.qortal.utils.Base58; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotData { + + private byte[] tradePrivateKey; + + private String acctName; + private String tradeState; + + // Internal use - not shown via API + @XmlTransient + @Schema(hidden = true) + private int tradeStateValue; + + private String creatorAddress; + private String atAddress; + + private long timestamp; + + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long qortAmount; + + private byte[] tradeNativePublicKey; + private byte[] tradeNativePublicKeyHash; + String tradeNativeAddress; + + private byte[] secret; + private byte[] hashOfSecret; + + private String foreignBlockchain; + private byte[] tradeForeignPublicKey; + private byte[] tradeForeignPublicKeyHash; + + @Deprecated + @Schema(description = "DEPRECATED: use foreignAmount instead", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long bitcoinAmount; + + @Schema(description = "amount in foreign blockchain currency", type = "number") + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long foreignAmount; + + // Never expose this via API + @XmlTransient + @Schema(hidden = true) + private String foreignKey; + + private byte[] lastTransactionSignature; + private Integer lockTimeA; + + // Could be Bitcoin or Qortal... + private byte[] receivingAccountInfo; + + protected TradeBotData() { + /* JAXB */ + } + + public TradeBotData(byte[] tradePrivateKey, String acctName, String tradeState, int tradeStateValue, + String creatorAddress, String atAddress, + long timestamp, long qortAmount, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, + byte[] secret, byte[] hashOfSecret, + String foreignBlockchain, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, + long foreignAmount, String foreignKey, + byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { + this.tradePrivateKey = tradePrivateKey; + this.acctName = acctName; + this.tradeState = tradeState; + this.tradeStateValue = tradeStateValue; + this.creatorAddress = creatorAddress; + this.atAddress = atAddress; + this.timestamp = timestamp; + this.qortAmount = qortAmount; + this.tradeNativePublicKey = tradeNativePublicKey; + this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; + this.tradeNativeAddress = tradeNativeAddress; + this.secret = secret; + this.hashOfSecret = hashOfSecret; + this.foreignBlockchain = foreignBlockchain; + this.tradeForeignPublicKey = tradeForeignPublicKey; + this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; + // deprecated copy + this.bitcoinAmount = foreignAmount; + this.foreignAmount = foreignAmount; + this.foreignKey = foreignKey; + this.lastTransactionSignature = lastTransactionSignature; + this.lockTimeA = lockTimeA; + this.receivingAccountInfo = receivingAccountInfo; + } + + public byte[] getTradePrivateKey() { + return this.tradePrivateKey; + } + + public String getAcctName() { + return this.acctName; + } + + public String getState() { + return this.tradeState; + } + + public void setState(String state) { + this.tradeState = state; + } + + public int getStateValue() { + return this.tradeStateValue; + } + + public void setStateValue(int stateValue) { + this.tradeStateValue = stateValue; + } + + public String getCreatorAddress() { + return this.creatorAddress; + } + + public String getAtAddress() { + return this.atAddress; + } + + public void setAtAddress(String atAddress) { + this.atAddress = atAddress; + } + + public long getTimestamp() { + return this.timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public long getQortAmount() { + return this.qortAmount; + } + + public byte[] getTradeNativePublicKey() { + return this.tradeNativePublicKey; + } + + public byte[] getTradeNativePublicKeyHash() { + return this.tradeNativePublicKeyHash; + } + + public String getTradeNativeAddress() { + return this.tradeNativeAddress; + } + + public byte[] getSecret() { + return this.secret; + } + + public byte[] getHashOfSecret() { + return this.hashOfSecret; + } + + public String getForeignBlockchain() { + return this.foreignBlockchain; + } + + public byte[] getTradeForeignPublicKey() { + return this.tradeForeignPublicKey; + } + + public byte[] getTradeForeignPublicKeyHash() { + return this.tradeForeignPublicKeyHash; + } + + public long getForeignAmount() { + return this.foreignAmount; + } + + public String getForeignKey() { + return this.foreignKey; + } + + public byte[] getLastTransactionSignature() { + return this.lastTransactionSignature; + } + + public void setLastTransactionSignature(byte[] lastTransactionSignature) { + this.lastTransactionSignature = lastTransactionSignature; + } + + public Integer getLockTimeA() { + return this.lockTimeA; + } + + public void setLockTimeA(Integer lockTimeA) { + this.lockTimeA = lockTimeA; + } + + public byte[] getReceivingAccountInfo() { + return this.receivingAccountInfo; + } + + public JSONObject toJson() { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("tradePrivateKey", Base58.encode(this.getTradePrivateKey())); + jsonObject.put("acctName", this.getAcctName()); + jsonObject.put("tradeState", this.getState()); + jsonObject.put("tradeStateValue", this.getStateValue()); + jsonObject.put("creatorAddress", this.getCreatorAddress()); + jsonObject.put("atAddress", this.getAtAddress()); + jsonObject.put("timestamp", this.getTimestamp()); + jsonObject.put("qortAmount", this.getQortAmount()); + if (this.getTradeNativePublicKey() != null) jsonObject.put("tradeNativePublicKey", Base58.encode(this.getTradeNativePublicKey())); + if (this.getTradeNativePublicKeyHash() != null) jsonObject.put("tradeNativePublicKeyHash", Base58.encode(this.getTradeNativePublicKeyHash())); + jsonObject.put("tradeNativeAddress", this.getTradeNativeAddress()); + if (this.getSecret() != null) jsonObject.put("secret", Base58.encode(this.getSecret())); + if (this.getHashOfSecret() != null) jsonObject.put("hashOfSecret", Base58.encode(this.getHashOfSecret())); + jsonObject.put("foreignBlockchain", this.getForeignBlockchain()); + if (this.getTradeForeignPublicKey() != null) jsonObject.put("tradeForeignPublicKey", Base58.encode(this.getTradeForeignPublicKey())); + if (this.getTradeForeignPublicKeyHash() != null) jsonObject.put("tradeForeignPublicKeyHash", Base58.encode(this.getTradeForeignPublicKeyHash())); + jsonObject.put("foreignKey", this.getForeignKey()); + jsonObject.put("foreignAmount", this.getForeignAmount()); + if (this.getLastTransactionSignature() != null) jsonObject.put("lastTransactionSignature", Base58.encode(this.getLastTransactionSignature())); + jsonObject.put("lockTimeA", this.getLockTimeA()); + if (this.getReceivingAccountInfo() != null) jsonObject.put("receivingAccountInfo", Base58.encode(this.getReceivingAccountInfo())); + return jsonObject; + } + + public static TradeBotData fromJson(JSONObject json) { + return new TradeBotData( + json.isNull("tradePrivateKey") ? null : Base58.decode(json.getString("tradePrivateKey")), + json.isNull("acctName") ? null : json.getString("acctName"), + json.isNull("tradeState") ? null : json.getString("tradeState"), + json.isNull("tradeStateValue") ? null : json.getInt("tradeStateValue"), + json.isNull("creatorAddress") ? null : json.getString("creatorAddress"), + json.isNull("atAddress") ? null : json.getString("atAddress"), + json.isNull("timestamp") ? null : json.getLong("timestamp"), + json.isNull("qortAmount") ? null : json.getLong("qortAmount"), + json.isNull("tradeNativePublicKey") ? null : Base58.decode(json.getString("tradeNativePublicKey")), + json.isNull("tradeNativePublicKeyHash") ? null : Base58.decode(json.getString("tradeNativePublicKeyHash")), + json.isNull("tradeNativeAddress") ? null : json.getString("tradeNativeAddress"), + json.isNull("secret") ? null : Base58.decode(json.getString("secret")), + json.isNull("hashOfSecret") ? null : Base58.decode(json.getString("hashOfSecret")), + json.isNull("foreignBlockchain") ? null : json.getString("foreignBlockchain"), + json.isNull("tradeForeignPublicKey") ? null : Base58.decode(json.getString("tradeForeignPublicKey")), + json.isNull("tradeForeignPublicKeyHash") ? null : Base58.decode(json.getString("tradeForeignPublicKeyHash")), + json.isNull("foreignAmount") ? null : json.getLong("foreignAmount"), + json.isNull("foreignKey") ? null : json.getString("foreignKey"), + json.isNull("lastTransactionSignature") ? null : Base58.decode(json.getString("lastTransactionSignature")), + json.isNull("lockTimeA") ? null : json.getInt("lockTimeA"), + json.isNull("receivingAccountInfo") ? null : Base58.decode(json.getString("receivingAccountInfo")) + ); + } + + // Mostly for debugging + public String toString() { + return String.format("%s: %s (%d)", this.atAddress, this.tradeState, this.tradeStateValue); + } + +} diff --git a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java new file mode 100644 index 00000000..001bd5b4 --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -0,0 +1,73 @@ +package org.qortal.data.transaction; + +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.TransactionType; + +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.media.Schema.AccessMode; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +@Schema(allOf = { TransactionData.class }) +public class PresenceTransactionData extends TransactionData { + + // Properties + @Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + private byte[] senderPublicKey; + + @Schema(accessMode = AccessMode.READ_ONLY) + private int nonce; + + private PresenceType presenceType; + + @Schema(description = "timestamp signature", example = "2yGEbwRFyhPZZckKA") + private byte[] timestampSignature; + + // Constructors + + // For JAXB + protected PresenceTransactionData() { + super(TransactionType.PRESENCE); + } + + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.senderPublicKey; + } + + public PresenceTransactionData(BaseTransactionData baseTransactionData, + int nonce, PresenceType presenceType, byte[] timestampSignature) { + super(TransactionType.PRESENCE, baseTransactionData); + + this.senderPublicKey = baseTransactionData.creatorPublicKey; + this.nonce = nonce; + this.presenceType = presenceType; + this.timestampSignature = timestampSignature; + } + + // Getters/Setters + + public byte[] getSenderPublicKey() { + return this.senderPublicKey; + } + + public int getNonce() { + return this.nonce; + } + + public void setNonce(int nonce) { + this.nonce = nonce; + } + + public PresenceType getPresenceType() { + return this.presenceType; + } + + public byte[] getTimestampSignature() { + return this.timestampSignature; + } + +} diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 397693b8..060901f2 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -40,7 +40,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; GroupApprovalTransactionData.class, SetGroupTransactionData.class, UpdateAssetTransactionData.class, AccountFlagsTransactionData.class, RewardShareTransactionData.class, - AccountLevelTransactionData.class, ChatTransactionData.class + AccountLevelTransactionData.class, ChatTransactionData.class, PresenceTransactionData.class }) //All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java new file mode 100644 index 00000000..70ebdbf9 --- /dev/null +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -0,0 +1,21 @@ +package org.qortal.repository; + +import java.util.List; + +import org.qortal.data.crosschain.TradeBotData; + +public interface CrossChainRepository { + + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException; + + /** Returns true if there is an existing trade-bot entry relating to given AT address, excluding trade-bot entries with given states. */ + public boolean existsTradeWithAtExcludingStates(String atAddress, List excludeStates) throws DataException; + + public List getAllTradeBotData() throws DataException; + + public void save(TradeBotData tradeBotData) throws DataException; + + /** Delete trade-bot states using passed private key. */ + public int delete(byte[] tradePrivateKey) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 9cdfe26c..656e6e1e 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -14,6 +14,8 @@ public interface Repository extends AutoCloseable { public ChatRepository getChatRepository(); + public CrossChainRepository getCrossChainRepository(); + public GroupRepository getGroupRepository(); public MessageRepository getMessageRepository(); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java new file mode 100644 index 00000000..29f2994c --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -0,0 +1,202 @@ +package org.qortal.repository.hsqldb; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.repository.CrossChainRepository; +import org.qortal.repository.DataException; + +public class HSQLDBCrossChainRepository implements CrossChainRepository { + + protected HSQLDBRepository repository; + + public HSQLDBCrossChainRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + @Override + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { + String sql = "SELECT acct_name, trade_state, trade_state_value, " + + "creator_address, at_address, " + + "updated_when, qort_amount, " + + "trade_native_public_key, trade_native_public_key_hash, " + + "trade_native_address, secret, hash_of_secret, " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + + "FROM TradeBotStates " + + "WHERE trade_private_key = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, tradePrivateKey)) { + if (resultSet == null) + return null; + + String acctName = resultSet.getString(1); + String tradeState = resultSet.getString(2); + int tradeStateValue = resultSet.getInt(3); + String creatorAddress = resultSet.getString(4); + String atAddress = resultSet.getString(5); + long timestamp = resultSet.getLong(6); + long qortAmount = resultSet.getLong(7); + byte[] tradeNativePublicKey = resultSet.getBytes(8); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(9); + String tradeNativeAddress = resultSet.getString(10); + byte[] secret = resultSet.getBytes(11); + byte[] hashOfSecret = resultSet.getBytes(12); + String foreignBlockchain = resultSet.getString(13); + byte[] tradeForeignPublicKey = resultSet.getBytes(14); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(15); + long foreignAmount = resultSet.getLong(16); + String foreignKey = resultSet.getString(17); + byte[] lastTransactionSignature = resultSet.getBytes(18); + Integer lockTimeA = resultSet.getInt(19); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + byte[] receivingAccountInfo = resultSet.getBytes(20); + + return new TradeBotData(tradePrivateKey, acctName, + tradeState, tradeStateValue, + creatorAddress, atAddress, timestamp, qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading state from repository", e); + } + } + + @Override + public boolean existsTradeWithAtExcludingStates(String atAddress, List excludeStates) throws DataException { + if (excludeStates == null) + excludeStates = Collections.emptyList(); + + StringBuilder whereClause = new StringBuilder(256); + whereClause.append("at_address = ?"); + + Object[] bindParams = new Object[1 + excludeStates.size()]; + bindParams[0] = atAddress; + + if (!excludeStates.isEmpty()) { + whereClause.append(" AND trade_state NOT IN (?"); + bindParams[1] = excludeStates.get(0); + + for (int i = 1; i < excludeStates.size(); ++i) { + whereClause.append(", ?"); + bindParams[1 + i] = excludeStates.get(i); + } + + whereClause.append(")"); + } + + try { + return this.repository.exists("TradeBotStates", whereClause.toString(), bindParams); + } catch (SQLException e) { + throw new DataException("Unable to check for trade-bot state in repository", e); + } + } + + @Override + public List getAllTradeBotData() throws DataException { + String sql = "SELECT trade_private_key, acct_name, trade_state, trade_state_value, " + + "creator_address, at_address, " + + "updated_when, qort_amount, " + + "trade_native_public_key, trade_native_public_key_hash, " + + "trade_native_address, secret, hash_of_secret, " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + + "FROM TradeBotStates"; + + List allTradeBotData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return allTradeBotData; + + do { + byte[] tradePrivateKey = resultSet.getBytes(1); + String acctName = resultSet.getString(2); + String tradeState = resultSet.getString(3); + int tradeStateValue = resultSet.getInt(4); + String creatorAddress = resultSet.getString(5); + String atAddress = resultSet.getString(6); + long timestamp = resultSet.getLong(7); + long qortAmount = resultSet.getLong(8); + byte[] tradeNativePublicKey = resultSet.getBytes(9); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(10); + String tradeNativeAddress = resultSet.getString(11); + byte[] secret = resultSet.getBytes(12); + byte[] hashOfSecret = resultSet.getBytes(13); + String foreignBlockchain = resultSet.getString(14); + byte[] tradeForeignPublicKey = resultSet.getBytes(15); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(16); + long foreignAmount = resultSet.getLong(17); + String foreignKey = resultSet.getString(18); + byte[] lastTransactionSignature = resultSet.getBytes(19); + Integer lockTimeA = resultSet.getInt(20); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + byte[] receivingAccountInfo = resultSet.getBytes(21); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acctName, + tradeState, tradeStateValue, + creatorAddress, atAddress, timestamp, qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); + allTradeBotData.add(tradeBotData); + } while (resultSet.next()); + + return allTradeBotData; + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading states from repository", e); + } + } + + @Override + public void save(TradeBotData tradeBotData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates"); + + saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) + .bind("acct_name", tradeBotData.getAcctName()) + .bind("trade_state", tradeBotData.getState()) + .bind("trade_state_value", tradeBotData.getStateValue()) + .bind("creator_address", tradeBotData.getCreatorAddress()) + .bind("at_address", tradeBotData.getAtAddress()) + .bind("updated_when", tradeBotData.getTimestamp()) + .bind("qort_amount", tradeBotData.getQortAmount()) + .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) + .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) + .bind("trade_native_address", tradeBotData.getTradeNativeAddress()) + .bind("secret", tradeBotData.getSecret()) + .bind("hash_of_secret", tradeBotData.getHashOfSecret()) + .bind("foreign_blockchain", tradeBotData.getForeignBlockchain()) + .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) + .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) + .bind("foreign_amount", tradeBotData.getForeignAmount()) + .bind("foreign_key", tradeBotData.getForeignKey()) + .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) + .bind("locktime_a", tradeBotData.getLockTimeA()) + .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save trade bot data into repository", e); + } + } + + @Override + public int delete(byte[] tradePrivateKey) throws DataException { + try { + return this.repository.delete("TradeBotStates", "trade_private_key = ?", tradePrivateKey); + } catch (SQLException e) { + throw new DataException("Unable to delete trade-bot states from repository", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index d6d48acc..9964117b 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -7,6 +7,7 @@ import java.sql.Statement; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot; public class HSQLDBDatabaseUpdates { @@ -616,6 +617,17 @@ public class HSQLDBDatabaseUpdates { break; case 20: + // Trade bot + // See case 25 below for changes + stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + + "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, " + + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + + "trade_native_address QortalAddress NOT NULL, secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, " + + "trade_foreign_public_key VARBINARY(33) NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " + + "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, " + + "receiving_account_info VARBINARY(32) NOT NULL, PRIMARY KEY (trade_private_key))"); + break; + case 21: // AT functionality index stmt.execute("CREATE INDEX IF NOT EXISTS ATCodeHashIndex ON ATs (code_hash, is_finished)"); @@ -697,6 +709,14 @@ public class HSQLDBDatabaseUpdates { } } + try (ResultSet resultSet = stmt.executeQuery("SELECT COUNT(*) FROM TradeBotStates")) { + int rowCount = resultSet.next() ? resultSet.getInt(1) : 0; + if (rowCount > 0) { + stmt.execute("PERFORM EXPORT SCRIPT FOR TABLE TradeBotStates DATA TO 'TradeBotStates.script'"); + LOGGER.info("Exported sensitive/node-local trade-bot states into TradeBotStates.script"); + } + } + LOGGER.info("If following reshape takes too long, use bootstrap and import node-local data using API's POST /admin/repository/data"); } @@ -761,6 +781,37 @@ public class HSQLDBDatabaseUpdates { break; case 32: + // Multiple blockchains, ACCTs and trade-bots + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN acct_name VARCHAR(40) BEFORE trade_state"); + stmt.execute("UPDATE TradeBotStates SET acct_name = 'BitcoinACCTv1' WHERE acct_name IS NULL"); + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN acct_name SET NOT NULL"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state RENAME TO trade_state_value"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN trade_state VARCHAR(40) BEFORE trade_state_value"); + // Any existing values will be BitcoinACCTv1 + StringBuilder updateTradeBotStatesSql = new StringBuilder(1024); + updateTradeBotStatesSql.append("UPDATE TradeBotStates SET (trade_state) = (") + .append("SELECT state_name FROM (VALUES ") + .append( + Arrays.stream(BitcoinACCTv1TradeBot.State.values()) + .map(state -> String.format("(%d, '%s')", state.value, state.name())) + .collect(Collectors.joining(", "))) + .append(") AS BitcoinACCTv1States (state_value, state_name) ") + .append("WHERE state_value = trade_state_value)"); + stmt.execute(updateTradeBotStatesSql.toString()); + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state SET NOT NULL"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN foreign_blockchain VARCHAR(40) BEFORE trade_foreign_public_key"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN bitcoin_amount RENAME TO foreign_amount"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN xprv58 RENAME TO foreign_key"); + + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN secret SET NULL"); + stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN hash_of_secret SET NULL"); + break; + case 33: // PRESENCE transactions stmt.execute("CREATE TABLE IF NOT EXISTS PresenceTransactions (" diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 02de9f5f..09c6a6d4 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -28,6 +28,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.account.PrivateKeyAccount; import org.qortal.crypto.Crypto; +import org.qortal.data.crosschain.TradeBotData; import org.qortal.globalization.Translator; import org.qortal.gui.SysTray; import org.qortal.repository.ATRepository; @@ -36,6 +37,7 @@ import org.qortal.repository.ArbitraryRepository; import org.qortal.repository.AssetRepository; import org.qortal.repository.BlockRepository; import org.qortal.repository.ChatRepository; +import org.qortal.repository.CrossChainRepository; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.MessageRepository; @@ -74,6 +76,7 @@ public class HSQLDBRepository implements Repository { private final AssetRepository assetRepository = new HSQLDBAssetRepository(this); private final BlockRepository blockRepository = new HSQLDBBlockRepository(this); private final ChatRepository chatRepository = new HSQLDBChatRepository(this); + private final CrossChainRepository crossChainRepository = new HSQLDBCrossChainRepository(this); private final GroupRepository groupRepository = new HSQLDBGroupRepository(this); private final MessageRepository messageRepository = new HSQLDBMessageRepository(this); private final NameRepository nameRepository = new HSQLDBNameRepository(this); @@ -144,6 +147,11 @@ public class HSQLDBRepository implements Repository { return this.chatRepository; } + @Override + public CrossChainRepository getCrossChainRepository() { + return this.crossChainRepository; + } + @Override public GroupRepository getGroupRepository() { return this.groupRepository; @@ -450,12 +458,68 @@ public class HSQLDBRepository implements Repository { @Override public void exportNodeLocalData() throws DataException { - // TODO + // Create the qortal-backup folder if it doesn't exist + Path backupPath = Paths.get("qortal-backup"); + try { + Files.createDirectories(backupPath); + } catch (IOException e) { + LOGGER.info("Unable to create backup folder"); + throw new DataException("Unable to create backup folder"); + } + + try { + // Load trade bot data + List allTradeBotData = this.getCrossChainRepository().getAllTradeBotData(); + JSONArray allTradeBotDataJson = new JSONArray(); + for (TradeBotData tradeBotData : allTradeBotData) { + JSONObject tradeBotDataJson = tradeBotData.toJson(); + allTradeBotDataJson.put(tradeBotDataJson); + } + + // We need to combine existing TradeBotStates data before overwriting + String fileName = "qortal-backup/TradeBotStates.json"; + File tradeBotStatesBackupFile = new File(fileName); + if (tradeBotStatesBackupFile.exists()) { + String jsonString = new String(Files.readAllBytes(Paths.get(fileName))); + JSONArray allExistingTradeBotData = new JSONArray(jsonString); + Iterator iterator = allExistingTradeBotData.iterator(); + while(iterator.hasNext()) { + JSONObject existingTradeBotData = (JSONObject)iterator.next(); + String existingTradePrivateKey = (String) existingTradeBotData.get("tradePrivateKey"); + // Check if we already have an entry for this trade + boolean found = allTradeBotData.stream().anyMatch(tradeBotData -> Base58.encode(tradeBotData.getTradePrivateKey()).equals(existingTradePrivateKey)); + if (found == false) + // We need to add this to our list + allTradeBotDataJson.put(existingTradeBotData); + } + } + + FileWriter writer = new FileWriter(fileName); + writer.write(allTradeBotDataJson.toString()); + writer.close(); + LOGGER.info("Exported sensitive/node-local data: trade bot states"); + + } catch (DataException | IOException e) { + throw new DataException("Unable to export trade bot states from repository"); + } } @Override public void importDataFromFile(String filename) throws DataException { - // TODO + LOGGER.info(() -> String.format("Importing data into repository from %s", filename)); + try { + String jsonString = new String(Files.readAllBytes(Paths.get(filename))); + JSONArray tradeBotDataToImport = new JSONArray(jsonString); + Iterator iterator = tradeBotDataToImport.iterator(); + while(iterator.hasNext()) { + JSONObject tradeBotDataJson = (JSONObject)iterator.next(); + TradeBotData tradeBotData = TradeBotData.fromJson(tradeBotDataJson); + this.getCrossChainRepository().save(tradeBotData); + } + } catch (IOException e) { + throw new DataException("Unable to import sensitive/node-local trade bot states to repository: " + e.getMessage()); + } + LOGGER.info(() -> String.format("Imported trade bot states into repository from %s", filename)); } @Override @@ -992,4 +1056,4 @@ public class HSQLDBRepository implements Repository { return DEADLOCK_ERROR_CODE.equals(e.getErrorCode()); } -} +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java new file mode 100644 index 00000000..309ffcad --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java @@ -0,0 +1,57 @@ +package org.qortal.repository.hsqldb.transaction; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.hsqldb.HSQLDBRepository; +import org.qortal.repository.hsqldb.HSQLDBSaver; +import org.qortal.transaction.PresenceTransaction.PresenceType; + +public class HSQLDBPresenceTransactionRepository extends HSQLDBTransactionRepository { + + public HSQLDBPresenceTransactionRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { + String sql = "SELECT nonce, presence_type, timestamp_signature FROM PresenceTransactions WHERE signature = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { + if (resultSet == null) + return null; + + int nonce = resultSet.getInt(1); + int presenceTypeValue = resultSet.getInt(2); + PresenceType presenceType = PresenceType.valueOf(presenceTypeValue); + + byte[] timestampSignature = resultSet.getBytes(3); + + return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature); + } catch (SQLException e) { + throw new DataException("Unable to fetch presence transaction from repository", e); + } + } + + @Override + public void save(TransactionData transactionData) throws DataException { + PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData; + + HSQLDBSaver saveHelper = new HSQLDBSaver("PresenceTransactions"); + + saveHelper.bind("signature", presenceTransactionData.getSignature()) + .bind("nonce", presenceTransactionData.getNonce()) + .bind("presence_type", presenceTransactionData.getPresenceType().value) + .bind("timestamp_signature", presenceTransactionData.getTimestampSignature()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save chat transaction into repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d0ff70e1..7c26fc22 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -24,6 +24,8 @@ import org.eclipse.persistence.exceptions.XMLMarshalException; import org.eclipse.persistence.jaxb.JAXBContextFactory; import org.eclipse.persistence.jaxb.UnmarshallerProperties; import org.qortal.block.BlockChain; +import org.qortal.crosschain.Bitcoin.BitcoinNet; +import org.qortal.crosschain.Litecoin.LitecoinNet; // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) @@ -155,6 +157,8 @@ public class Settings { // Which blockchains this node is running private String blockchainConfig = null; // use default from resources + private BitcoinNet bitcoinNet = BitcoinNet.MAIN; + private LitecoinNet litecoinNet = LitecoinNet.MAIN; // Also crosschain-related: /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; @@ -507,6 +511,14 @@ public class Settings { return this.blockchainConfig; } + public BitcoinNet getBitcoinNet() { + return this.bitcoinNet; + } + + public LitecoinNet getLitecoinNet() { + return this.litecoinNet; + } + public boolean isTradebotSystrayEnabled() { return this.tradebotSystrayEnabled; } diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java new file mode 100644 index 00000000..729270e0 --- /dev/null +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -0,0 +1,256 @@ +package org.qortal.transaction; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.Account; +import org.qortal.controller.Controller; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crypto.Crypto; +import org.qortal.crypto.MemoryPoW; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.PresenceTransactionTransformer; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.ByteArray; + +import com.google.common.primitives.Longs; + +public class PresenceTransaction extends Transaction { + + private static final Logger LOGGER = LogManager.getLogger(PresenceTransaction.class); + + // Properties + private PresenceTransactionData presenceTransactionData; + + // Other useful constants + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + public static final int POW_DIFFICULTY = 8; // leading zero bits + + public enum PresenceType { + REWARD_SHARE(0) { + @Override + public long getLifetime() { + return Controller.ONLINE_TIMESTAMP_MODULUS; + } + }, + TRADE_BOT(1) { + @Override + public long getLifetime() { + return 30 * 60 * 1000L; // 30 minutes in milliseconds + } + }; + + public final int value; + private static final Map map = stream(PresenceType.values()).collect(toMap(type -> type.value, type -> type)); + + PresenceType(int value) { + this.value = value; + } + + public abstract long getLifetime(); + + public static PresenceType valueOf(int value) { + return map.get(value); + } + + /** Returns PresenceType with matching name or null (instead of throwing IllegalArgumentException). */ + public static PresenceType fromString(String name) { + try { + return PresenceType.valueOf(name); + } catch (IllegalArgumentException e) { + return null; + } + } + } + + // Constructors + + public PresenceTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.presenceTransactionData = (PresenceTransactionData) this.transactionData; + } + + // More information + + @Override + public long getDeadline() { + return this.transactionData.getTimestamp() + this.presenceTransactionData.getPresenceType().getLifetime(); + } + + @Override + public List getRecipientAddresses() throws DataException { + return Collections.emptyList(); + } + + // Navigation + + public Account getSender() { + return this.getCreator(); + } + + // Processing + + public void computeNonce() throws DataException { + byte[] transactionBytes; + + try { + transactionBytes = TransactionTransformer.toBytesForSigning(this.transactionData); + } catch (TransformationException e) { + throw new RuntimeException("Unable to transform transaction to byte array for verification", e); + } + + // Clear nonce from transactionBytes + PresenceTransactionTransformer.clearNonce(transactionBytes); + + // Calculate nonce + this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY)); + } + + /** + * Returns whether PRESENCE transaction has valid txGroupId. + *

+ * We insist on NO_GROUP. + */ + @Override + protected boolean isValidTxGroupId() throws DataException { + int txGroupId = this.transactionData.getTxGroupId(); + + return txGroupId == Group.NO_GROUP; + } + + @Override + public ValidationResult isFeeValid() throws DataException { + if (this.transactionData.getFee() < 0) + return ValidationResult.NEGATIVE_FEE; + + return ValidationResult.OK; + } + + @Override + public boolean hasValidReference() throws DataException { + return true; + } + + @Override + public ValidationResult isValid() throws DataException { + // Nonce checking is done via isSignatureValid() as that method is only called once per import + + // If we exist in the repository then we've been imported as unconfirmed, + // but we don't want to make it into a block, so return fake non-OK result. + if (this.repository.getTransactionRepository().exists(this.presenceTransactionData.getSignature())) + return ValidationResult.INVALID_BUT_OK; + + // We only support TRADE_BOT-type PRESENCE at this time + if (PresenceType.TRADE_BOT != this.presenceTransactionData.getPresenceType()) + return ValidationResult.NOT_YET_RELEASED; + + // Check timestamp signature + byte[] timestampSignature = this.presenceTransactionData.getTimestampSignature(); + byte[] timestampBytes = Longs.toByteArray(this.presenceTransactionData.getTimestamp()); + if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), timestampSignature, timestampBytes)) + return ValidationResult.INVALID_TIMESTAMP_SIGNATURE; + + Map> acctSuppliersByCodeHash = SupportedBlockchain.getAcctMap(); + Set codeHashes = acctSuppliersByCodeHash.keySet(); + boolean isExecutable = true; + + List atsData = repository.getATRepository().getAllATsByFunctionality(codeHashes, isExecutable); + + // Convert signer's public key to address form + String signerAddress = Crypto.toAddress(this.transactionData.getCreatorPublicKey()); + + for (ATData atData : atsData) { + ByteArray atCodeHash = new ByteArray(atData.getCodeHash()); + Supplier acctSupplier = acctSuppliersByCodeHash.get(atCodeHash); + if (acctSupplier == null) + continue; + + CrossChainTradeData crossChainTradeData = acctSupplier.get().populateTradeData(repository, atData); + + // OK if signer's public key (in address form) matches Bob's trade public key (in address form) + if (signerAddress.equals(crossChainTradeData.qortalCreatorTradeAddress)) + return ValidationResult.OK; + + // OK if signer's public key (in address form) matches Alice's trade public key (in address form) + if (signerAddress.equals(crossChainTradeData.qortalPartnerAddress)) + return ValidationResult.OK; + } + + return ValidationResult.AT_UNKNOWN; + } + + @Override + public boolean isSignatureValid() { + byte[] signature = this.transactionData.getSignature(); + if (signature == null) + return false; + + byte[] transactionBytes; + + try { + transactionBytes = PresenceTransactionTransformer.toBytesForSigning(this.transactionData); + } catch (TransformationException e) { + throw new RuntimeException("Unable to transform transaction to byte array for verification", e); + } + + if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), signature, transactionBytes)) + return false; + + int nonce = this.presenceTransactionData.getNonce(); + + // Clear nonce from transactionBytes + PresenceTransactionTransformer.clearNonce(transactionBytes); + + // Check nonce + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce); + } + + /** + * Remove any PRESENCE transactions by the same signer that have older timestamps. + */ + @Override + protected void onImportAsUnconfirmed() throws DataException { + byte[] creatorPublicKey = this.transactionData.getCreatorPublicKey(); + List creatorsPresenceTransactions = this.repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, creatorPublicKey); + + if (creatorsPresenceTransactions.isEmpty()) + return; + + for (TransactionData transactionData : creatorsPresenceTransactions) { + if (transactionData.getTimestamp() >= this.transactionData.getTimestamp()) + continue; + + LOGGER.debug(() -> String.format("Deleting older PRESENCE transaction %s", Base58.encode(transactionData.getSignature()))); + this.repository.getTransactionRepository().delete(transactionData); + } + } + + @Override + public void process() throws DataException { + throw new DataException("PRESENCE transactions should never be processed"); + } + + @Override + public void orphan() throws DataException { + throw new DataException("PRESENCE transactions should never be orphaned"); + } + +} diff --git a/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java new file mode 100644 index 00000000..bf69d102 --- /dev/null +++ b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java @@ -0,0 +1,108 @@ +package org.qortal.transform.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; + +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transform.TransformationException; +import org.qortal.utils.Serialization; + +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; + +public class PresenceTransactionTransformer extends TransactionTransformer { + + // Property lengths + private static final int NONCE_LENGTH = INT_LENGTH; + private static final int PRESENCE_TYPE_LENGTH = BYTE_LENGTH; + private static final int TIMESTAMP_SIGNATURE_LENGTH = SIGNATURE_LENGTH; + + private static final int EXTRAS_LENGTH = NONCE_LENGTH + PRESENCE_TYPE_LENGTH + TIMESTAMP_SIGNATURE_LENGTH; + + protected static final TransactionLayout layout; + + static { + layout = new TransactionLayout(); + layout.add("txType: " + TransactionType.PRESENCE.valueString, TransformationType.INT); + layout.add("timestamp", TransformationType.TIMESTAMP); + layout.add("transaction's groupID", TransformationType.INT); + layout.add("reference", TransformationType.SIGNATURE); + layout.add("sender's public key", TransformationType.PUBLIC_KEY); + layout.add("proof-of-work nonce", TransformationType.INT); + layout.add("presence type (reward-share=0, trade-bot=1)", TransformationType.BYTE); + layout.add("timestamp-signature", TransformationType.SIGNATURE); + layout.add("fee", TransformationType.AMOUNT); + layout.add("signature", TransformationType.SIGNATURE); + } + + public static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException { + long timestamp = byteBuffer.getLong(); + + int txGroupId = byteBuffer.getInt(); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer); + + int nonce = byteBuffer.getInt(); + + PresenceType presenceType = PresenceType.valueOf(byteBuffer.get()); + + byte[] timestampSignature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(timestampSignature); + + long fee = byteBuffer.getLong(); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); + + return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature); + } + + public static int getDataLength(TransactionData transactionData) { + return getBaseLength(transactionData) + EXTRAS_LENGTH; + } + + public static byte[] toBytes(TransactionData transactionData) throws TransformationException { + try { + PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + transformCommonBytes(transactionData, bytes); + + bytes.write(Ints.toByteArray(presenceTransactionData.getNonce())); + + bytes.write(presenceTransactionData.getPresenceType().value); + + bytes.write(presenceTransactionData.getTimestampSignature()); + + bytes.write(Longs.toByteArray(presenceTransactionData.getFee())); + + if (presenceTransactionData.getSignature() != null) + bytes.write(presenceTransactionData.getSignature()); + + return bytes.toByteArray(); + } catch (IOException | ClassCastException e) { + throw new TransformationException(e); + } + } + + public static void clearNonce(byte[] transactionBytes) { + int nonceIndex = TYPE_LENGTH + TIMESTAMP_LENGTH + GROUPID_LENGTH + REFERENCE_LENGTH + PUBLIC_KEY_LENGTH; + + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + transactionBytes[nonceIndex++] = (byte) 0; + } + +} diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java new file mode 100644 index 00000000..b53b72cb --- /dev/null +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -0,0 +1,133 @@ +package org.qortal.test; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.PresenceTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.PresenceTransaction; +import org.qortal.transaction.PresenceTransaction.PresenceType; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.NTP; + +import com.google.common.primitives.Longs; + +import static org.junit.Assert.*; + +public class PresenceTests extends Common { + + private static final byte[] BITCOIN_PKH = new byte[20]; + private static final byte[] HASH_OF_SECRET_B = new byte[32]; + + private PrivateKeyAccount signer; + private Repository repository; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + + this.repository = RepositoryManager.getRepository(); + this.signer = Common.getTestAccount(this.repository, "bob"); + + // We need to create corresponding test trade offer + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(this.signer.getAddress(), BITCOIN_PKH, HASH_OF_SECRET_B, + 0L, 0L, + 7 * 24 * 60 * 60); + + long txTimestamp = NTP.getTime(); + byte[] lastReference = this.signer.getLastReference(); + + long fee = 0; + String name = "QORT-BTC cross-chain trade"; + String description = "Qortal-Bitcoin cross-chain trade"; + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, this.signer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, 1L, Asset.QORT); + + Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(this.repository, deployAtTransactionData, this.signer); + BlockUtils.mintBlock(this.repository); + } + + @After + public void afterTest() throws DataException { + if (this.repository != null) + this.repository.close(); + + this.repository = null; + } + + @Test + public void validityTests() throws DataException { + long timestamp = System.currentTimeMillis(); + byte[] timestampBytes = Longs.toByteArray(timestamp); + + byte[] timestampSignature = this.signer.sign(timestampBytes); + + assertTrue(isValid(Group.NO_GROUP, this.signer, timestamp, timestampSignature)); + + PrivateKeyAccount nonTrader = Common.getTestAccount(repository, "alice"); + assertFalse(isValid(Group.NO_GROUP, nonTrader, timestamp, timestampSignature)); + } + + @Test + public void newestOnlyTests() throws DataException { + long OLDER_TIMESTAMP = System.currentTimeMillis() - 2000L; + long NEWER_TIMESTAMP = OLDER_TIMESTAMP + 1000L; + + PresenceTransaction older = buildPresenceTransaction(Group.NO_GROUP, this.signer, OLDER_TIMESTAMP, null); + older.computeNonce(); + TransactionUtils.signAndImportValid(repository, older.getTransactionData(), this.signer); + + assertTrue(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature())); + + PresenceTransaction newer = buildPresenceTransaction(Group.NO_GROUP, this.signer, NEWER_TIMESTAMP, null); + newer.computeNonce(); + TransactionUtils.signAndImportValid(repository, newer.getTransactionData(), this.signer); + + assertTrue(this.repository.getTransactionRepository().exists(newer.getTransactionData().getSignature())); + assertFalse(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature())); + } + + private boolean isValid(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException { + Transaction transaction = buildPresenceTransaction(txGroupId, signer, timestamp, timestampSignature); + return transaction.isValidUnconfirmed() == ValidationResult.OK; + } + + private PresenceTransaction buildPresenceTransaction(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException { + int nonce = 0; + + byte[] reference = signer.getLastReference(); + byte[] creatorPublicKey = signer.getPublicKey(); + long fee = 0L; + + if (timestampSignature == null) + timestampSignature = this.signer.sign(Longs.toByteArray(timestamp)); + + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null); + PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature); + + return new PresenceTransaction(this.repository, transactionData); + } + +} diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 269e2aa3..434e03f0 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -4,6 +4,7 @@ import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.asset.Asset; +import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -393,6 +394,25 @@ public class RepositoryTests extends Common { } } + /** Specifically test LATERAL() usage in AT repository */ + @Test + public void testAtLateral() { + try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { + byte[] codeHash = BitcoinACCTv1.CODE_BYTES_HASH; + Boolean isFinished = null; + Integer dataByteOffset = null; + Long expectedValue = null; + Integer minimumFinalHeight = 2; + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + hsqldb.getATRepository().getMatchingFinalATStates(codeHash, isFinished, dataByteOffset, expectedValue, minimumFinalHeight, limit, offset, reverse); + } catch (DataException e) { + fail("HSQLDB bug #1580"); + } + } + /** Specifically test LATERAL() usage in Chat repository */ @Test public void testChatLateral() { diff --git a/src/test/java/org/qortal/test/api/CrossChainApiTests.java b/src/test/java/org/qortal/test/api/CrossChainApiTests.java new file mode 100644 index 00000000..d4f25bce --- /dev/null +++ b/src/test/java/org/qortal/test/api/CrossChainApiTests.java @@ -0,0 +1,42 @@ +package org.qortal.test.api; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.api.ApiError; +import org.qortal.api.resource.CrossChainResource; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.test.common.ApiCommon; + +public class CrossChainApiTests extends ApiCommon { + + private static final SupportedBlockchain SPECIFIC_BLOCKCHAIN = null; + + private CrossChainResource crossChainResource; + + @Before + public void buildResource() { + this.crossChainResource = (CrossChainResource) ApiCommon.buildResource(CrossChainResource.class); + } + + @Test + public void testGetTradeOffers() { + assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getTradeOffers(SPECIFIC_BLOCKCHAIN, limit, offset, reverse)); + } + + @Test + public void testGetCompletedTrades() { + long minimumTimestamp = System.currentTimeMillis(); + assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, minimumTimestamp, limit, offset, reverse)); + } + + @Test + public void testInvalidGetCompletedTrades() { + Integer limit = null; + Integer offset = null; + Boolean reverse = null; + + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, -1L /*minimumTimestamp*/, limit, offset, reverse)); + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, 0L /*minimumTimestamp*/, limit, offset, reverse)); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java new file mode 100644 index 00000000..af879e08 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -0,0 +1,115 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.store.BlockStoreException; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class BitcoinTests extends Common { + + private Bitcoin bitcoin; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + bitcoin = Bitcoin.getInstance(); + } + + @After + public void afterTest() { + Bitcoin.resetForTesting(); + bitcoin = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + System.out.println(String.format("Starting BTC instance...")); + System.out.println(String.format("BTC instance started")); + + long before = System.currentTimeMillis(); + System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + + assertNotNull(secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = bitcoin.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + + // Check spent key caching doesn't affect outcome + + transaction = bitcoin.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + } + + @Test + public void testGetWalletBalance() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = bitcoin.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(bitcoin.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = bitcoin.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(bitcoin.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = bitcoin.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/ElectrumXTests.java b/src/test/java/org/qortal/test/crosschain/ElectrumXTests.java new file mode 100644 index 00000000..b7e57cf3 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/ElectrumXTests.java @@ -0,0 +1,201 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import java.security.Security; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import org.bitcoinj.core.Address; +import org.bitcoinj.params.TestNet3Params; +import org.bitcoinj.script.ScriptBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.junit.Test; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.BitcoinyTransaction; +import org.qortal.crosschain.ElectrumX; +import org.qortal.crosschain.TransactionHash; +import org.qortal.crosschain.UnspentOutput; +import org.qortal.crosschain.Bitcoin.BitcoinNet; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; +import org.qortal.utils.BitTwiddling; + +import com.google.common.hash.HashCode; + +public class ElectrumXTests { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + } + + private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class); + static { + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001); + DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002); + } + + private ElectrumX getInstance() { + return new ElectrumX("Bitcoin-" + BitcoinNet.TEST3.name(), BitcoinNet.TEST3.getGenesisHash(), BitcoinNet.TEST3.getServers(), DEFAULT_ELECTRUMX_PORTS); + } + + @Test + public void testInstance() { + ElectrumX electrumX = getInstance(); + assertNotNull(electrumX); + } + + @Test + public void testGetCurrentHeight() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + int height = electrumX.getCurrentHeight(); + + assertTrue(height > 10000); + System.out.println("Current TEST3 height: " + height); + } + + @Test + public void testInvalidRequest() { + ElectrumX electrumX = getInstance(); + try { + electrumX.getRawBlockHeaders(-1, -1); + } catch (ForeignBlockchainException e) { + // Should throw due to negative start block height + return; + } + + fail("Negative start block height should cause error"); + } + + @Test + public void testGetRecentBlocks() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + int height = electrumX.getCurrentHeight(); + assertTrue(height > 10000); + + List recentBlockHeaders = electrumX.getRawBlockHeaders(height - 11, 11); + + System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size())); + for (int i = 0; i < recentBlockHeaders.size(); ++i) { + byte[] blockHeader = recentBlockHeaders.get(i); + + // Timestamp(int) is at 4 + 32 + 32 = 68 bytes offset + int offset = 4 + 32 + 32; + int timestamp = BitTwiddling.intFromLEBytes(blockHeader, offset); + System.out.println(String.format("Block %d timestamp: %d", height + i, timestamp)); + } + } + + @Test + public void testGetP2PKHBalance() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + long balance = electrumX.getConfirmedBalance(script); + + assertTrue(balance > 0L); + + System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); + } + + @Test + public void testGetP2SHBalance() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + long balance = electrumX.getConfirmedBalance(script); + + assertTrue(balance > 0L); + + System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); + } + + @Test + public void testGetUnspentOutputs() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + List unspentOutputs = electrumX.getUnspentOutputs(script, false); + + assertFalse(unspentOutputs.isEmpty()); + + for (UnspentOutput unspentOutput : unspentOutputs) + System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.hash), unspentOutput.index)); + } + + @Test + public void testGetRawTransaction() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes(); + + byte[] rawTransactionBytes = electrumX.getRawTransaction(txHash); + + assertFalse(rawTransactionBytes.length == 0); + } + + @Test + public void testGetUnknownRawTransaction() { + ElectrumX electrumX = getInstance(); + + byte[] txHash = HashCode.fromString("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0").asBytes(); + + try { + electrumX.getRawTransaction(txHash); + fail("Bitcoin transaction should be unknown and hence throw exception"); + } catch (ForeignBlockchainException e) { + if (!(e instanceof ForeignBlockchainException.NotFoundException)) + fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); + } + } + + @Test + public void testGetTransaction() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + String txHash = "7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af"; + + BitcoinyTransaction transaction = electrumX.getTransaction(txHash); + + assertNotNull(transaction); + assertTrue(transaction.txHash.equals(txHash)); + } + + @Test + public void testGetUnknownTransaction() { + ElectrumX electrumX = getInstance(); + + String txHash = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"; + + try { + electrumX.getTransaction(txHash); + fail("Bitcoin transaction should be unknown and hence throw exception"); + } catch (ForeignBlockchainException e) { + if (!(e instanceof ForeignBlockchainException.NotFoundException)) + fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); + } + } + + @Test + public void testGetAddressTransactions() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); + + Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List transactionHashes = electrumX.getAddressTransactions(script, false); + + assertFalse(transactionHashes.isEmpty()); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/HtlcTests.java b/src/test/java/org/qortal/test/crosschain/HtlcTests.java new file mode 100644 index 00000000..75b290bf --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/HtlcTests.java @@ -0,0 +1,128 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crypto.Crypto; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +import com.google.common.primitives.Longs; + +public class HtlcTests extends Common { + + private Bitcoin bitcoin; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + bitcoin = Bitcoin.getInstance(); + } + + @After + public void afterTest() { + Bitcoin.resetForTesting(); + bitcoin = null; + } + + @Test + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + + assertNotNull(secret); + assertArrayEquals("secret incorrect", expectedSecret, secret); + } + + @Test + @Ignore(value = "Doesn't work, to be fixed later") + public void testHtlcSecretCaching() throws ForeignBlockchainException { + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + + do { + // We need to perform fresh setup for 1st test + Bitcoin.resetForTesting(); + bitcoin = Bitcoin.getInstance(); + + long now = System.currentTimeMillis(); + long timestampBoundary = now / 30_000L; + + byte[] secret1 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + long executionPeriod1 = System.currentTimeMillis() - now; + + assertNotNull(secret1); + assertArrayEquals("secret1 incorrect", expectedSecret, secret1); + + assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); + + byte[] secret2 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); + long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; + + assertNotNull(secret2); + assertArrayEquals("secret2 incorrect", expectedSecret, secret2); + + // Test is only valid if we've called within same timestampBoundary + if (System.currentTimeMillis() / 30_000L != timestampBoundary) + continue; + + assertArrayEquals(secret1, secret2); + + assertTrue("2st execution period should be effectively instant!", executionPeriod2 < 10); + } while (false); + } + + @Test + public void testDetermineHtlcStatus() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + assertNotNull(htlcStatus); + + System.out.println(String.format("HTLC %s status: %s", p2shAddress, htlcStatus.name())); + } + + @Test + public void testHtlcStatusCaching() throws ForeignBlockchainException { + do { + // We need to perform fresh setup for 1st test + Bitcoin.resetForTesting(); + bitcoin = Bitcoin.getInstance(); + + long now = System.currentTimeMillis(); + long timestampBoundary = now / 30_000L; + + // Won't ever exist + String p2shAddress = bitcoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now))); + + BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + long executionPeriod1 = System.currentTimeMillis() - now; + + assertNotNull(htlcStatus1); + assertTrue("1st execution period should not be instant!", executionPeriod1 > 10); + + BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L); + long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1; + + assertNotNull(htlcStatus2); + assertEquals(htlcStatus1, htlcStatus2); + + // Test is only valid if we've called within same timestampBoundary + if (System.currentTimeMillis() / 30_000L != timestampBoundary) + continue; + + assertTrue("2st execution period should be effectively instant!", executionPeriod2 < 10); + } while (false); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java new file mode 100644 index 00000000..64837347 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -0,0 +1,114 @@ +package org.qortal.test.crosschain; + +import static org.junit.Assert.*; + +import java.util.Arrays; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.store.BlockStoreException; +import org.junit.After; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class LitecoinTests extends Common { + + private Litecoin litecoin; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + litecoin = Litecoin.getInstance(); + } + + @After + public void afterTest() { + Litecoin.resetForTesting(); + litecoin = null; + } + + @Test + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { + long before = System.currentTimeMillis(); + System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); + long afterFirst = System.currentTimeMillis(); + + System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime())); + long afterSecond = System.currentTimeMillis(); + + long firstPeriod = afterFirst - before; + long secondPeriod = afterSecond - afterFirst; + + System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod)); + + assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod); + assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L); + } + + @Test + @Ignore(value = "Doesn't work, to be fixed later") + public void testFindHtlcSecret() throws ForeignBlockchainException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress); + + assertNotNull("secret not found", secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testBuildSpend() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + Transaction transaction = litecoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + + // Check spent key caching doesn't affect outcome + + transaction = litecoin.buildSpend(xprv58, recipient, amount); + assertNotNull("insufficient funds", transaction); + } + + @Test + public void testGetWalletBalance() { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = litecoin.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(litecoin.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = litecoin.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(litecoin.format(repeatBalance)); + + assertEquals(balance, repeatBalance); + } + + @Test + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + String address = litecoin.getUnusedReceiveAddress(xprv58); + + assertNotNull(address); + + System.out.println(address); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java new file mode 100644 index 00000000..fa92fde7 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java @@ -0,0 +1,114 @@ +package org.qortal.test.crosschain.apps; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; + +import com.google.common.hash.HashCode; + +public class BuildHTLC { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: BuildHTLC (-b | -l) ")); + System.err.println("where: -b means use Bitcoin, -l means use Litecoin"); + System.err.println(String.format("example: BuildHTLC -l " + + "msAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n" + + "\t0.00008642 \\\n" + + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n" + + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + + "\t1600000000")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 6 || args.length > 6) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = null; + NetworkParameters params = null; + + Address refundAddress = null; + Coin amount = null; + Address redeemAddress = null; + byte[] hashOfSecret = null; + int lockTime = 0; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + params = bitcoiny.getNetworkParameters(); + + refundAddress = Address.fromString(params, args[argIndex++]); + if (refundAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Refund address must be in P2PKH form"); + + amount = Coin.parseCoin(args[argIndex++]); + + redeemAddress = Address.fromString(params, args[argIndex++]); + if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Redeem address must be in P2PKH form"); + + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) + usage("Hash of secret must be 20 bytes"); + + lockTime = Integer.parseInt(args[argIndex++]); + int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L); + if (refundTimeoutDelay < 600 || refundTimeoutDelay > 30 * 24 * 60 * 60) + usage("Locktime (seconds) should be at between 10 minutes and 1 month from now"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny)); + if (p2shFee.isZero()) + return; + + System.out.println(String.format("Refund address: %s", refundAddress)); + System.out.println(String.format("Amount: %s", amount.toPlainString())); + System.out.println(String.format("Redeem address: %s", redeemAddress)); + System.out.println(String.format("Refund/redeem miner's fee: %s", bitcoiny.format(p2shFee))); + System.out.println(String.format("Script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); + System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(hashOfSecret))); + + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret); + System.out.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes))); + + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScriptBytes); + System.out.println(String.format("P2SH address: %s", p2shAddress)); + + amount = amount.add(p2shFee); + + // Fund P2SH + System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)", + p2shAddress, bitcoiny.format(amount), bitcoiny.format(p2shFee))); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java new file mode 100644 index 00000000..8b1cc423 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java @@ -0,0 +1,135 @@ +package org.qortal.test.crosschain.apps; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crypto.Crypto; + +import com.google.common.hash.HashCode; + +public class CheckHTLC { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: CheckHTLC (-b | -l) ")); + System.err.println("where: -b means use Bitcoin, -l means use Litecoin"); + System.err.println(String.format("example: CheckP2SH -l " + + "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n" + + "msAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n" + + "\t0.00008642 \\\n" + + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n" + + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + + "\t1600184800")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 7 || args.length > 7) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = null; + NetworkParameters params = null; + + Address p2shAddress = null; + Address refundAddress = null; + Coin amount = null; + Address redeemAddress = null; + byte[] hashOfSecret = null; + int lockTime = 0; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + params = bitcoiny.getNetworkParameters(); + + p2shAddress = Address.fromString(params, args[argIndex++]); + if (p2shAddress.getOutputScriptType() != ScriptType.P2SH) + usage("P2SH address invalid"); + + refundAddress = Address.fromString(params, args[argIndex++]); + if (refundAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Refund address must be in P2PKH form"); + + amount = Coin.parseCoin(args[argIndex++]); + + redeemAddress = Address.fromString(params, args[argIndex++]); + if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Redeem address must be in P2PKH form"); + + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) + usage("Hash of secret must be 20 bytes"); + + lockTime = Integer.parseInt(args[argIndex++]); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny)); + if (p2shFee.isZero()) + return; + + System.out.println(String.format("P2SH address: %s", p2shAddress)); + System.out.println(String.format("Refund PKH: %s", refundAddress)); + System.out.println(String.format("Redeem/refund amount: %s", amount.toPlainString())); + System.out.println(String.format("Redeem PKH: %s", redeemAddress)); + System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(hashOfSecret))); + System.out.println(String.format("Script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); + + System.out.println(String.format("Redeem/refund miner's fee: %s", bitcoiny.format(p2shFee))); + + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret); + System.out.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes))); + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!derivedP2shAddress.equals(p2shAddress)) { + System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress)); + System.exit(2); + } + + amount = amount.add(p2shFee); + + // Check network's median block time + int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, null); + if (medianBlockTime == 0) + return; + + // Check P2SH is funded + Common.getBalance(bitcoiny, p2shAddress.toString()); + + // Grab all unspent outputs + Common.getUnspentOutputs(bitcoiny, p2shAddress.toString()); + + Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), amount.value); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/Common.java b/src/test/java/org/qortal/test/crosschain/apps/Common.java new file mode 100644 index 00000000..78066fe7 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/Common.java @@ -0,0 +1,158 @@ +package org.qortal.test.crosschain.apps; + +import java.security.Security; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.List; + +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.settings.Settings; +import org.qortal.utils.NTP; + +import com.google.common.hash.HashCode; + +public abstract class Common { + + public static void init() { + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + Settings.fileInstance("settings-test.json"); + + NTP.setFixedOffset(0L); + } + + public static long getP2shFee(Bitcoiny bitcoiny) { + long p2shFee; + + try { + p2shFee = bitcoiny.getP2shFee(null); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Unable to determine P2SH fee: %s", e.getMessage())); + return 0; + } + + return p2shFee; + } + + public static int checkMedianBlockTime(Bitcoiny bitcoiny, Integer lockTime) { + int medianBlockTime; + + try { + medianBlockTime = bitcoiny.getMedianBlockTime(); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Unable to determine median block time: %s", e.getMessage())); + return 0; + } + + System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); + + long now = System.currentTimeMillis(); + + if (now < medianBlockTime * 1000L) { + System.out.println(String.format("Too soon (%s) based on median block time %s", + LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), + LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); + return 0; + } + + if (lockTime != null && now < lockTime * 1000L) { + System.err.println(String.format("Too soon (%s) based on lockTime %s", + LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), + LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC))); + return 0; + } + + return medianBlockTime; + } + + public static long getBalance(Bitcoiny bitcoiny, String address58) { + long balance; + + try { + balance = bitcoiny.getConfirmedBalance(address58); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Unable to check address %s balance: %s", address58, e.getMessage())); + return 0; + } + + System.out.println(String.format("Address %s balance: %s", address58, bitcoiny.format(balance))); + + return balance; + } + + public static List getUnspentOutputs(Bitcoiny bitcoiny, String address58) { + List unspentOutputs = Collections.emptyList(); + + try { + unspentOutputs = bitcoiny.getUnspentOutputs(address58); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Can't find unspent outputs for %s: %s", address58, e.getMessage())); + return unspentOutputs; + } + + System.out.println(String.format("Found %d output%s for %s", + unspentOutputs.size(), + (unspentOutputs.size() != 1 ? "s" : ""), + address58)); + + for (TransactionOutput fundingOutput : unspentOutputs) + System.out.println(String.format("Output %s:%d amount %s", + HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), + bitcoiny.format(fundingOutput.getValue()))); + + if (unspentOutputs.isEmpty()) + System.err.println(String.format("Can't use spent/unfunded %s", address58)); + + if (unspentOutputs.size() != 1) + System.err.println(String.format("Expecting only one unspent output?")); + + return unspentOutputs; + } + + public static BitcoinyHTLC.Status determineHtlcStatus(Bitcoiny bitcoiny, String address58, long minimumAmount) { + BitcoinyHTLC.Status htlcStatus = null; + + try { + htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), address58, minimumAmount); + + System.out.println(String.format("HTLC status: %s", htlcStatus.name())); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Unable to determine HTLC status: %s", e.getMessage())); + } + + return htlcStatus; + } + + public static void broadcastTransaction(Bitcoiny bitcoiny, Transaction transaction) { + byte[] rawTransactionBytes = transaction.bitcoinSerialize(); + + System.out.println(String.format("%nRaw transaction bytes:%n%s%n", HashCode.fromBytes(rawTransactionBytes).toString())); + + for (int countDown = 5; countDown >= 1; --countDown) { + System.out.print(String.format("\rBroadcasting transaction in %d second%s... use CTRL-C to abort ", countDown, (countDown != 1 ? "s" : ""))); + try { + Thread.sleep(1000L); + } catch (InterruptedException e) { + System.exit(0); + } + } + System.out.println("Broadcasting transaction... "); + + try { + bitcoiny.broadcastTransaction(transaction); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Failed to broadcast transaction: %s", e.getMessage())); + System.exit(1); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java b/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java new file mode 100644 index 00000000..ef22355b --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java @@ -0,0 +1,78 @@ +package org.qortal.test.crosschain.apps; + +import java.security.Security; + +import org.bitcoinj.core.AddressFormatException; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.settings.Settings; + +public class GetNextReceiveAddress { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: GetNextReceiveAddress (-b | -l) ")); + System.err.println(String.format("example (testnet): GetNextReceiveAddress -l tpubD6NzVbkrYhZ4X3jV96Wo3Kr8Au2v9cUUEmPRk1smwduFrRVfBjkkw49rRYjgff1fGSktFMfabbvv8b1dmfyLjjbDax6QGyxpsNsx5PXukCB")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 2) + usage(null); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + Settings.fileInstance("settings-test.json"); + + Bitcoiny bitcoiny = null; + String key58 = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + + key58 = args[argIndex++]; + + if (!bitcoiny.isValidDeterministicKey(key58)) + usage("Not valid xprv/xpub/tprv/tpub"); + } catch (NumberFormatException | AddressFormatException e) { + usage(String.format("Argument format exception: %s", e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + String receiveAddress = null; + try { + receiveAddress = bitcoiny.getUnusedReceiveAddress(key58); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Failed to determine next receive address: %s", e.getMessage())); + System.exit(1); + } + + System.out.println(String.format("Next receive address: %s", receiveAddress)); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java b/src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java new file mode 100644 index 00000000..9d903a56 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java @@ -0,0 +1,84 @@ +package org.qortal.test.crosschain.apps; + +import java.security.Security; +import java.util.List; + +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.TransactionOutput; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.settings.Settings; + +import com.google.common.hash.HashCode; + +public class GetTransaction { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: GetTransaction (-b | -l) ")); + System.err.println(String.format("example (mainnet): GetTransaction -b 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660")); + System.err.println(String.format("example (testnet): GetTransaction -b 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 2) + usage(null); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + Settings.fileInstance("settings-test.json"); + + Bitcoiny bitcoiny = null; + byte[] transactionId = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + + transactionId = HashCode.fromString(args[argIndex++]).asBytes(); + } catch (NumberFormatException | AddressFormatException e) { + usage(String.format("Argument format exception: %s", e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + // Grab all outputs from transaction + List fundingOutputs; + try { + fundingOutputs = bitcoiny.getOutputs(transactionId); + } catch (ForeignBlockchainException e) { + System.out.println(String.format("Transaction not found (or error occurred)")); + return; + } + + System.out.println(String.format("Found %d output%s", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); + + for (TransactionOutput fundingOutput : fundingOutputs) + System.out.println(String.format("Output %d: %s", fundingOutput.getIndex(), fundingOutput.getValue().toPlainString())); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java b/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java new file mode 100644 index 00000000..7a880b1a --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java @@ -0,0 +1,82 @@ +package org.qortal.test.crosschain.apps; + +import java.security.Security; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import org.bitcoinj.core.AddressFormatException; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.crosschain.*; +import org.qortal.settings.Settings; + +public class GetWalletTransactions { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: GetWalletTransactions (-b | -l) ")); + System.err.println(String.format("example (testnet): GetWalletTransactions -l tpubD6NzVbkrYhZ4X3jV96Wo3Kr8Au2v9cUUEmPRk1smwduFrRVfBjkkw49rRYjgff1fGSktFMfabbvv8b1dmfyLjjbDax6QGyxpsNsx5PXukCB")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 2) + usage(null); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + Settings.fileInstance("settings-test.json"); + + Bitcoiny bitcoiny = null; + String key58 = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + + key58 = args[argIndex++]; + + if (!bitcoiny.isValidDeterministicKey(key58)) + usage("Not valid xprv/xpub/tprv/tpub"); + } catch (NumberFormatException | AddressFormatException e) { + usage(String.format("Argument format exception: %s", e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + // Grab all outputs from transaction + List transactions = null; + try { + transactions = bitcoiny.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Failed to obtain wallet transactions: %s", e.getMessage())); + System.exit(1); + } + + System.out.println(String.format("Found %d transaction%s", transactions.size(), (transactions.size() != 1 ? "s" : ""))); + + for (SimpleTransaction transaction : transactions.stream().sorted(Comparator.comparingInt(SimpleTransaction::getTimestamp)).collect(Collectors.toList())) + System.out.println(String.format("%s", transaction)); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/Pay.java b/src/test/java/org/qortal/test/crosschain/apps/Pay.java new file mode 100644 index 00000000..93c7aede --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/Pay.java @@ -0,0 +1,80 @@ +package org.qortal.test.crosschain.apps; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Transaction; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.Litecoin; + +public class Pay { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: Pay (-b | -l) ")); + System.err.println("where: -b means use Bitcoin, -l means use Litecoin"); + System.err.println(String.format("example: Pay -l " + + "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ \\\n" + + "\tmsAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n" + + "\t0.00008642")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 4 || args.length > 4) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = null; + NetworkParameters params = null; + + String xprv58 = null; + Address address = null; + Coin amount = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + params = bitcoiny.getNetworkParameters(); + + xprv58 = args[argIndex++]; + if (!bitcoiny.isValidDeterministicKey(xprv58)) + usage("xprv invalid"); + + address = Address.fromString(params, args[argIndex++]); + + amount = Coin.parseCoin(args[argIndex++]); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + System.out.println(String.format("Address: %s", address)); + System.out.println(String.format("Amount: %s", amount.toPlainString())); + + Transaction transaction = bitcoiny.buildSpend(xprv58, address.toString(), amount.value); + if (transaction == null) { + System.err.println("Insufficent funds"); + System.exit(1); + } + + Common.broadcastTransaction(bitcoiny, transaction); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java new file mode 100644 index 00000000..d4f1bcf1 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java @@ -0,0 +1,166 @@ +package org.qortal.test.crosschain.apps; + +import java.util.Arrays; +import java.util.List; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crypto.Crypto; + +import com.google.common.hash.HashCode; + +public class RedeemHTLC { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: Redeem (-b | -l) ")); + System.err.println("where: -b means use Bitcoin, -l means use Litecoin"); + System.err.println(String.format("example: Redeem -l " + + "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n" + + "\tmsAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n" + + "\tefdaed23c4bc85c8ccae40d774af3c2a10391c648b6420cdd83cd44c27fcb5955201c64e372d \\\n" + + "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n" + + "\t1600184800 \\\n" + + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 7 || args.length > 7) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = null; + NetworkParameters params = null; + + Address p2shAddress = null; + Address refundAddress = null; + byte[] redeemPrivateKey = null; + byte[] secret = null; + int lockTime = 0; + Address outputAddress = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + params = bitcoiny.getNetworkParameters(); + + p2shAddress = Address.fromString(params, args[argIndex++]); + if (p2shAddress.getOutputScriptType() != ScriptType.P2SH) + usage("P2SH address invalid"); + + refundAddress = Address.fromString(params, args[argIndex++]); + if (refundAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Refund address must be in P2PKH form"); + + redeemPrivateKey = HashCode.fromString(args[argIndex++]).asBytes(); + // Auto-trim + if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38) + redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33); + if (redeemPrivateKey.length != 32) + usage("Redeem private key must be 32 bytes"); + + secret = HashCode.fromString(args[argIndex++]).asBytes(); + if (secret.length == 0) + usage("Invalid secret bytes"); + + lockTime = Integer.parseInt(args[argIndex++]); + + outputAddress = Address.fromString(params, args[argIndex++]); + if (outputAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Output address invalid"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny)); + if (p2shFee.isZero()) + return; + + System.out.println(String.format("Attempting to redeem HTLC %s to %s", p2shAddress, outputAddress)); + + byte[] hashOfSecret = Crypto.hash160(secret); + + ECKey redeemKey = ECKey.fromPrivate(redeemPrivateKey); + Address redeemAddress = Address.fromKey(params, redeemKey, ScriptType.P2PKH); + + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret); + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!derivedP2shAddress.equals(p2shAddress)) { + System.err.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes))); + System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress)); + System.exit(2); + return; + } + + // Actual live processing... + + int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, null); + if (medianBlockTime == 0) + return; + + // Check P2SH is funded + long p2shBalance = Common.getBalance(bitcoiny, p2shAddress.toString()); + if (p2shBalance == 0) + return; + + // Grab all unspent outputs + List unspentOutputs = Common.getUnspentOutputs(bitcoiny, p2shAddress.toString()); + if (unspentOutputs.isEmpty()) + return; + + Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(p2shFee); + + BitcoinyHTLC.Status htlcStatus = Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), redeemAmount.value); + if (htlcStatus == null) + return; + + if (htlcStatus != BitcoinyHTLC.Status.FUNDED) { + System.err.println(String.format("Expecting %s HTLC status, but actual status is %s", "FUNDED", htlcStatus.name())); + System.exit(2); + return; + } + + System.out.println(String.format("Spending %s of outputs, with %s as mining fee", bitcoiny.format(redeemAmount), bitcoiny.format(p2shFee))); + + Transaction redeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey, + unspentOutputs, redeemScriptBytes, secret, outputAddress.getHash()); + + Common.broadcastTransaction(bitcoiny, redeemTransaction); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java new file mode 100644 index 00000000..723185f0 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java @@ -0,0 +1,163 @@ +package org.qortal.test.crosschain.apps; + +import java.util.Arrays; +import java.util.List; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; +import org.qortal.crosschain.Litecoin; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crypto.Crypto; + +import com.google.common.hash.HashCode; + +public class RefundHTLC { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: RefundHTLC (-b | -l) ")); + System.err.println("where: -b means use Bitcoin, -l means use Litecoin"); + System.err.println(String.format("example: RefundHTLC -l " + + "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n" + + "\tef8f31b49c31b4a140aebcd9605fded88cc2dad0844c4b984f9191a5a416f72d3801e16447b0 \\\n" + + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n" + + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + + "\t1600184800 \\\n" + + "\tmoJtbbhs7T4Z5hmBH2iyKhGrCWBzQWS2CL")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length < 7 || args.length > 7) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = null; + NetworkParameters params = null; + + Address p2shAddress = null; + byte[] refundPrivateKey = null; + Address redeemAddress = null; + byte[] hashOfSecret = null; + int lockTime = 0; + Address outputAddress = null; + + int argIndex = 0; + try { + switch (args[argIndex++]) { + case "-b": + bitcoiny = Bitcoin.getInstance(); + break; + + case "-l": + bitcoiny = Litecoin.getInstance(); + break; + + default: + usage("Only Bitcoin (-b) or Litecoin (-l) supported"); + } + params = bitcoiny.getNetworkParameters(); + + p2shAddress = Address.fromString(params, args[argIndex++]); + if (p2shAddress.getOutputScriptType() != ScriptType.P2SH) + usage("P2SH address invalid"); + + refundPrivateKey = HashCode.fromString(args[argIndex++]).asBytes(); + // Auto-trim + if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38) + refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33); + if (refundPrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + redeemAddress = Address.fromString(params, args[argIndex++]); + if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Redeem address must be in P2PKH form"); + + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) + usage("HASH160 of secret must be 20 bytes"); + + lockTime = Integer.parseInt(args[argIndex++]); + + outputAddress = Address.fromString(params, args[argIndex++]); + if (outputAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Output address invalid"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId())); + + Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny)); + if (p2shFee.isZero()) + return; + + System.out.println(String.format("Attempting to refund HTLC %s to %s", p2shAddress, outputAddress)); + + ECKey refundKey = ECKey.fromPrivate(refundPrivateKey); + Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH); + + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret); + + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + + if (!derivedP2shAddress.equals(p2shAddress)) { + System.err.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes))); + System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress)); + System.exit(2); + } + + // Actual live processing... + + int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, lockTime); + if (medianBlockTime == 0) + return; + + // Check P2SH is funded + long p2shBalance = Common.getBalance(bitcoiny, p2shAddress.toString()); + if (p2shBalance == 0) + return; + + // Grab all unspent outputs + List unspentOutputs = Common.getUnspentOutputs(bitcoiny, p2shAddress.toString()); + if (unspentOutputs.isEmpty()) + return; + + Coin refundAmount = Coin.valueOf(p2shBalance).subtract(p2shFee); + + BitcoinyHTLC.Status htlcStatus = Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), refundAmount.value); + if (htlcStatus == null) + return; + + if (htlcStatus != BitcoinyHTLC.Status.FUNDED) { + System.err.println(String.format("Expecting %s HTLC status, but actual status is %s", "FUNDED", htlcStatus.name())); + System.exit(2); + return; + } + + System.out.println(String.format("Spending %s of outputs, with %s as mining fee", bitcoiny.format(refundAmount), bitcoiny.format(p2shFee))); + + Transaction refundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, + unspentOutputs, redeemScriptBytes, lockTime, outputAddress.getHash()); + + Common.broadcastTransaction(bitcoiny, refundTransaction); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java new file mode 100644 index 00000000..4487e874 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java @@ -0,0 +1,795 @@ +package org.qortal.test.crosschain.bitcoinv1; + +import static org.junit.Assert.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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.junit.Before; +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.BitcoinACCTv1; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Amounts; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +public class BitcoinACCTv1Tests extends Common { + + 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 = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long bitcoinAmount = 864200L; // 0.00864200 BTC + + private static final Random RANDOM = new Random(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testCompile() { + PrivateKeyAccount tradeAccount = createTradeAccount(null); + + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + } + + @Test + public void testDeploy() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = fundingAmount; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + expectedBalance = deployersInitialBalance; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = 0; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancel() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = BitcoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance - messageFee; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + 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 { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + BlockUtils.mintBlock(repository); + + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(AcctMode.OFFERING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretsCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secrets to AT, from correct account + messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + + // Orphan redeem + BlockUtils.orphanLastBlock(repository); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + + // Check AT state + ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); + + assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretsIncorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secrets to AT, but from wrong account + messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress()); + 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()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testIncorrectSecretsCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send incorrect secrets to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = BitcoinACCTv1.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Send incorrect secrets to AT, from correct account + messageData = BitcoinACCTv1.buildRedeemMessage(secretA, wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should still be in TRADE mode + tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secrets to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA, secretB); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testDescribeDeployed() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + List executableAts = repository.getATRepository().getAllExecutableATs(); + + for (ATData atData : executableAts) { + String atAddress = atData.getATAddress(); + byte[] codeBytes = atData.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); + + System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", + atAddress, + codeBytes.length, + (codeBytes.length != 1 ? "s": ""), + HashCode.fromBytes(codeHash))); + + // Not one of ours? + if (!Arrays.equals(codeHash, BitcoinACCTv1.CODE_BYTES_HASH)) + continue; + + describeAt(repository, atAddress); + } + } + } + + private int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-BTC cross-chain trade"; + String description = String.format("Qortal-Bitcoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout * 3 / 4 + 1; // close enough + + // AT should automatically refund deployer after 'refundTimeout' blocks + for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) + BlockUtils.mintBlock(repository); + + // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + } + + private void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + + 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" + + "\tmode: %s\n" + + "\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" + + "\tcurrent block height: %d,\n", + tradeData.qortalAtAddress, + tradeData.mode, + 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.expectedForeignAmount), + currentBlockHeight)); + + if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { + System.out.println(String.format("\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-B nLockTime: %d (%s),\n" + + "\ttrade partner: %s", + tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.lockTimeB, epochMilliFormatter.apply(tradeData.lockTimeB * 1000L), + tradeData.qortalPartnerAddress)); + } + } + + private PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java new file mode 100644 index 00000000..f27f7a7b --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java @@ -0,0 +1,169 @@ +package org.qortal.test.crosschain.bitcoinv1; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.controller.Controller; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryFactory; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.test.crosschain.apps.Common; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Amounts; +import org.qortal.utils.Base58; + +import com.google.common.hash.HashCode; + +public class DeployAT { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("example: DeployAT " + + "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n" + + "\t10 \\\n" + + "\t10.1 \\\n" + + "\t0.00864200 \\\n" + + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb (or mrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h) \\\n" + + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + + "\t10080")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 7) + usage(null); + + Common.init(); + + Bitcoiny bitcoiny = Bitcoin.getInstance(); + NetworkParameters params = bitcoiny.getNetworkParameters(); + + byte[] refundPrivateKey = null; + long redeemAmount = 0; + long fundingAmount = 0; + long expectedBitcoin = 0; + byte[] bitcoinPublicKeyHash = null; + byte[] hashOfSecret = null; + int tradeTimeout = 0; + + int argIndex = 0; + try { + refundPrivateKey = Base58.decode(args[argIndex++]); + if (refundPrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + redeemAmount = Long.parseLong(args[argIndex++]); + if (redeemAmount <= 0) + usage("QORT amount must be positive"); + + fundingAmount = Long.parseLong(args[argIndex++]); + if (fundingAmount <= redeemAmount) + usage("AT funding amount must be greater than QORT redeem amount"); + + expectedBitcoin = Long.parseLong(args[argIndex++]); + if (expectedBitcoin <= 0) + usage("Expected BTC amount must be positive"); + + String bitcoinPKHish = args[argIndex++]; + // Try P2PKH first + try { + Address bitcoinAddress = LegacyAddress.fromBase58(params, bitcoinPKHish); + bitcoinPublicKeyHash = bitcoinAddress.getHash(); + } catch (AddressFormatException e) { + // Try parsing as PKH hex string instead + bitcoinPublicKeyHash = HashCode.fromString(bitcoinPKHish).asBytes(); + } + if (bitcoinPublicKeyHash.length != 20) + usage("Bitcoin PKH must be 20 bytes"); + + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) + usage("Hash of secret must be 20 bytes"); + + tradeTimeout = Integer.parseInt(args[argIndex++]); + if (tradeTimeout < 60 || tradeTimeout > 50000) + usage("Trade timeout (minutes) must be between 60 and 50000"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + try { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } catch (DataException e) { + System.err.println(String.format("Repository start-up issue: %s", e.getMessage())); + System.exit(2); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount refundAccount = new PrivateKeyAccount(repository, refundPrivateKey); + System.out.println(String.format("Refund Qortal address: %s", refundAccount.getAddress())); + + System.out.println(String.format("QORT redeem amount: %s", Amounts.prettyAmount(redeemAmount))); + + System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount))); + + System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(hashOfSecret))); + + // Deploy AT + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecret, redeemAmount, expectedBitcoin, tradeTimeout); + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = refundAccount.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", refundAccount.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-BTC cross-chain trade"; + String description = String.format("Qortal-Bitcoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-BTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, refundAccount.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + deployAtTransaction.sign(refundAccount); + + byte[] signedBytes = null; + try { + signedBytes = TransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + System.err.println(String.format("Unable to convert transaction to base58: %s", e.getMessage())); + System.exit(2); + } + + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); + } catch (DataException e) { + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java new file mode 100644 index 00000000..3a1f9208 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java @@ -0,0 +1,150 @@ +package org.qortal.test.crosschain.litecoinv1; + +import java.math.BigDecimal; + +import org.bitcoinj.core.ECKey; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.controller.Controller; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryFactory; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.test.crosschain.apps.Common; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Amounts; +import org.qortal.utils.Base58; + +import com.google.common.hash.HashCode; + +public class DeployAT { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: DeployAT ")); + System.err.println("A trading key-pair will be generated for you!"); + System.err.println(String.format("example: DeployAT " + + "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n" + + "\t10 \\\n" + + "\t10.1 \\\n" + + "\t0.00864200 \\\n" + + "\t120")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 5) + usage(null); + + Common.init(); + + byte[] creatorPrivateKey = null; + long redeemAmount = 0; + long fundingAmount = 0; + long expectedLitecoin = 0; + int tradeTimeout = 0; + + int argIndex = 0; + try { + creatorPrivateKey = Base58.decode(args[argIndex++]); + if (creatorPrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + redeemAmount = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue(); + if (redeemAmount <= 0) + usage("QORT amount must be positive"); + + fundingAmount = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue(); + if (fundingAmount <= redeemAmount) + usage("AT funding amount must be greater than QORT redeem amount"); + + expectedLitecoin = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue(); + if (expectedLitecoin <= 0) + usage("Expected LTC amount must be positive"); + + tradeTimeout = Integer.parseInt(args[argIndex++]); + if (tradeTimeout < 60 || tradeTimeout > 50000) + usage("Trade timeout (minutes) must be between 60 and 50000"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + try { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } catch (DataException e) { + System.err.println(String.format("Repository start-up issue: %s", e.getMessage())); + System.exit(2); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount creatorAccount = new PrivateKeyAccount(repository, creatorPrivateKey); + System.out.println(String.format("Creator Qortal address: %s", creatorAccount.getAddress())); + System.out.println(String.format("QORT redeem amount: %s", Amounts.prettyAmount(redeemAmount))); + System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount))); + + // Generate trading key-pair + byte[] tradePrivateKey = new ECKey().getPrivKeyBytes(); + PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey); + byte[] litecoinPublicKeyHash = ECKey.fromPrivate(tradePrivateKey).getPubKeyHash(); + + System.out.println(String.format("Trade private key: %s", HashCode.fromBytes(tradePrivateKey))); + + // Deploy AT + byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, expectedLitecoin, tradeTimeout); + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = creatorAccount.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", creatorAccount.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-LTC cross-chain trade"; + String description = String.format("Qortal-Litecoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-LTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null); + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + deployAtTransaction.sign(creatorAccount); + + byte[] signedBytes = null; + try { + signedBytes = TransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + System.err.println(String.format("Unable to convert transaction to base58: %s", e.getMessage())); + System.exit(2); + } + + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); + + System.out.println(String.format("AT address: %s", atAddress)); + } catch (DataException e) { + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java new file mode 100644 index 00000000..609ff5f3 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java @@ -0,0 +1,770 @@ +package org.qortal.test.crosschain.litecoinv1; + +import static org.junit.Assert.*; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +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.junit.Before; +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.LitecoinACCTv1; +import org.qortal.crosschain.AcctMode; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Amounts; + +import com.google.common.hash.HashCode; +import com.google.common.primitives.Bytes; + +public class LitecoinACCTv1Tests extends Common { + + 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[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 20; // blocks + public static final long redeemAmount = 80_40200000L; + public static final long fundingAmount = 123_45600000L; + public static final long litecoinAmount = 864200L; // 0.00864200 LTC + + private static final Random RANDOM = new Random(); + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testCompile() { + PrivateKeyAccount tradeAccount = createTradeAccount(null); + + byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + } + + @Test + public void testDeploy() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = fundingAmount; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + expectedBalance = deployersInitialBalance; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = 0; + actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT); + + assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancel() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Send creator's address to AT, instead of typical partner's address + byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + + // Test orphaning + BlockUtils.orphanLastBlock(repository); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance - messageFee; + actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); + + // Check trade partner's Litecoin PKH was extracted correctly + assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + 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 { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); + + BlockUtils.mintBlock(repository); + + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(AcctMode.OFFERING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testAutomaticTradeRefund() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + // Check refund + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + // Check balances + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account + messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); + + // Orphan redeem + BlockUtils.orphanLastBlock(repository); + + // Check balances + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + + // Check AT state + ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); + + assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData())); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretIncorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, but from wrong account + messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress()); + 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()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + // Check balances + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testIncorrectSecretCorrectSender() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send incorrect secret to AT, from correct account + byte[] wrongSecret = new byte[32]; + RANDOM.nextBytes(wrongSecret); + messageData = LitecoinACCTv1.buildRedeemMessage(wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should still be in TRADE mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + + // Check eventual refund + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); + } + } + + @SuppressWarnings("unused") + @Test + public void testCorrectSecretCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secret to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA); + messageTransaction = sendMessage(repository, partner, 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()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); + } + } + + @SuppressWarnings("unused") + @Test + public void testDescribeDeployed() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); + + List executableAts = repository.getATRepository().getAllExecutableATs(); + + for (ATData atData : executableAts) { + String atAddress = atData.getATAddress(); + byte[] codeBytes = atData.getCodeBytes(); + byte[] codeHash = Crypto.digest(codeBytes); + + System.out.println(String.format("%s: code length: %d byte%s, code hash: %s", + atAddress, + codeBytes.length, + (codeBytes.length != 1 ? "s": ""), + HashCode.fromBytes(codeHash))); + + // Not one of ours? + if (!Arrays.equals(codeHash, LitecoinACCTv1.CODE_BYTES_HASH)) + continue; + + describeAt(repository, atAddress); + } + } + } + + private int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout); + + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "QORT-LTC cross-chain trade"; + String description = String.format("Qortal-Litecoin cross-chain trade"); + String atType = "ACCT"; + String tags = "QORT-LTC ACCT"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = tradeTimeout / 2 + 1; // close enough + + // AT should automatically refund deployer after 'refundTimeout' blocks + for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) + BlockUtils.mintBlock(repository); + + // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + } + + private void describeAt(Repository repository, String atAddress) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData); + + 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" + + "\tmode: %s\n" + + "\tcreator: %s,\n" + + "\tcreation timestamp: %s,\n" + + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + + "\tredeem payout: %s QORT,\n" + + "\texpected Litecoin: %s LTC,\n" + + "\tcurrent block height: %d,\n", + tradeData.qortalAtAddress, + tradeData.mode, + tradeData.qortalCreator, + epochMilliFormatter.apply(tradeData.creationTimestamp), + Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), + Amounts.prettyAmount(tradeData.qortAmount), + Amounts.prettyAmount(tradeData.expectedForeignAmount), + currentBlockHeight)); + + if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) { + System.out.println(String.format("\trefund timeout: %d minutes,\n" + + "\trefund height: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + + "\tLitecoin P2SH-A nLockTime: %d (%s),\n" + + "\ttrade partner: %s\n" + + "\tpartner's receiving address: %s", + tradeData.refundTimeout, + tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.qortalPartnerAddress, + tradeData.qortalPartnerReceivingAddress)); + } + } + + private PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java new file mode 100644 index 00000000..2d04098c --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java @@ -0,0 +1,90 @@ +package org.qortal.test.crosschain.litecoinv1; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.Controller; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryFactory; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.test.crosschain.apps.Common; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +public class SendCancelMessage { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: SendCancelMessage ")); + System.err.println(String.format("example: SendCancelMessage " + + "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n" + + "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 2) + usage(null); + + Common.init(); + + byte[] qortalPrivateKey = null; + String atAddress = null; + + int argIndex = 0; + try { + qortalPrivateKey = Base58.decode(args[argIndex++]); + if (qortalPrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + atAddress = args[argIndex++]; + if (!Crypto.isValidAtAddress(atAddress)) + usage("Invalid AT address"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + try { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } catch (DataException e) { + System.err.println(String.format("Repository start-up issue: %s", e.getMessage())); + System.exit(2); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount qortalAccount = new PrivateKeyAccount(repository, qortalPrivateKey); + + String creatorQortalAddress = qortalAccount.getAddress(); + System.out.println(String.format("Qortal address: %s", creatorQortalAddress)); + + byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(creatorQortalAddress); + MessageTransaction messageTransaction = MessageTransaction.build(repository, qortalAccount, Group.NO_GROUP, atAddress, messageData, false, false); + + System.out.println("Computing nonce..."); + messageTransaction.computeNonce(); + messageTransaction.sign(qortalAccount); + + byte[] signedBytes = null; + try { + signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData()); + } catch (TransformationException e) { + System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage())); + System.exit(2); + } + + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); + } catch (DataException e) { + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java new file mode 100644 index 00000000..20386d2a --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java @@ -0,0 +1,101 @@ +package org.qortal.test.crosschain.litecoinv1; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.Controller; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryFactory; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.test.crosschain.apps.Common; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import com.google.common.hash.HashCode; + +public class SendRedeemMessage { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: SendRedeemMessage ")); + System.err.println(String.format("example: SendRedeemMessage " + + "dbfe739f5a3ecf7b0a22cea71f73d86ec71355b740e5972bcdf9e8bb4721ab9d \\\n" + + "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\\n" + + "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n" + + "\tQqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 4) + usage(null); + + Common.init(); + + byte[] tradePrivateKey = null; + String atAddress = null; + byte[] secret = null; + String receiveAddress = null; + + int argIndex = 0; + try { + tradePrivateKey = HashCode.fromString(args[argIndex++]).asBytes(); + if (tradePrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + atAddress = args[argIndex++]; + if (!Crypto.isValidAtAddress(atAddress)) + usage("Invalid AT address"); + + secret = HashCode.fromString(args[argIndex++]).asBytes(); + if (secret.length != 32) + usage("Secret must be 32 bytes"); + + receiveAddress = args[argIndex++]; + if (!Crypto.isValidAddress(receiveAddress)) + usage("Invalid Qortal receive address"); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + try { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } catch (DataException e) { + System.err.println(String.format("Repository start-up issue: %s", e.getMessage())); + System.exit(2); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey); + + byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secret, receiveAddress); + MessageTransaction messageTransaction = MessageTransaction.build(repository, tradeAccount, Group.NO_GROUP, atAddress, messageData, false, false); + + System.out.println("Computing nonce..."); + messageTransaction.computeNonce(); + messageTransaction.sign(tradeAccount); + + byte[] signedBytes = null; + try { + signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData()); + } catch (TransformationException e) { + System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage())); + System.exit(2); + } + + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); + } catch (DataException e) { + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java new file mode 100644 index 00000000..83e9a20e --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java @@ -0,0 +1,118 @@ +package org.qortal.test.crosschain.litecoinv1; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.controller.Controller; +import org.qortal.crosschain.LitecoinACCTv1; +import org.qortal.crypto.Crypto; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryFactory; +import org.qortal.repository.RepositoryManager; +import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory; +import org.qortal.test.crosschain.apps.Common; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; + +import com.google.common.hash.HashCode; + +public class SendTradeMessage { + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: SendTradeMessage ")); + System.err.println(String.format("example: SendTradeMessage " + + "ed77aa2c62d785a9428725fc7f95b907be8a1cc43213239876a62cf70fdb6ecb \\\n" + + "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\\n" + + "\tQqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq \\\n" + + "\tffffffffffffffffffffffffffffffffffffffff \\\n" + + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + + "\t1600184800")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 6) + usage(null); + + Common.init(); + + byte[] tradePrivateKey = null; + String atAddress = null; + String partnerTradeAddress = null; + byte[] partnerTradePublicKeyHash = null; + byte[] hashOfSecret = null; + int lockTime = 0; + + int argIndex = 0; + try { + tradePrivateKey = HashCode.fromString(args[argIndex++]).asBytes(); + if (tradePrivateKey.length != 32) + usage("Refund private key must be 32 bytes"); + + atAddress = args[argIndex++]; + if (!Crypto.isValidAtAddress(atAddress)) + usage("Invalid AT address"); + + partnerTradeAddress = args[argIndex++]; + if (!Crypto.isValidAddress(partnerTradeAddress)) + usage("Invalid partner trade Qortal address"); + + partnerTradePublicKeyHash = HashCode.fromString(args[argIndex++]).asBytes(); + if (partnerTradePublicKeyHash.length != 20) + usage("Partner trade PKH must be 20 bytes"); + + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) + usage("HASH160 of secret must be 20 bytes"); + + lockTime = Integer.parseInt(args[argIndex++]); + } catch (IllegalArgumentException e) { + usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); + } + + try { + RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); + RepositoryManager.setRepositoryFactory(repositoryFactory); + } catch (DataException e) { + System.err.println(String.format("Repository start-up issue: %s", e.getMessage())); + System.exit(2); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey); + + int refundTimeout = LitecoinACCTv1.calcRefundTimeout(NTP.getTime(), lockTime); + if (refundTimeout < 1) { + System.err.println("Refund timeout too small. Is locktime in the past?"); + System.exit(2); + } + + byte[] messageData = LitecoinACCTv1.buildTradeMessage(partnerTradeAddress, partnerTradePublicKeyHash, hashOfSecret, lockTime, refundTimeout); + MessageTransaction messageTransaction = MessageTransaction.build(repository, tradeAccount, Group.NO_GROUP, atAddress, messageData, false, false); + + System.out.println("Computing nonce..."); + messageTransaction.computeNonce(); + messageTransaction.sign(tradeAccount); + + byte[] signedBytes = null; + try { + signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData()); + } catch (TransformationException e) { + System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage())); + System.exit(2); + } + + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); + } catch (DataException e) { + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); + } + } + +}