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