diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar
similarity index 78%
rename from lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar
rename to lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar
index 5abe2c77..45045ad3 100644
Binary files a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar and b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar differ
diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom
similarity index 94%
rename from lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom
rename to lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom
index 39af6ac7..b24b6706 100644
--- a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom
+++ b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom
@@ -4,6 +4,6 @@
4.0.0org.ciyamAT
- 1.3.4
+ 1.3.5POM was created from install:install-file
diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml
index 2cf6d13a..82cd311a 100644
--- a/lib/org/ciyam/AT/maven-metadata-local.xml
+++ b/lib/org/ciyam/AT/maven-metadata-local.xml
@@ -3,10 +3,11 @@
org.ciyamAT
- 1.3.4
+ 1.3.51.3.4
+ 1.3.5
- 20200414162728
+ 20200717104214
diff --git a/pom.xml b/pom.xml
index 26c32df2..43947614 100644
--- a/pom.xml
+++ b/pom.xml
@@ -9,7 +9,7 @@
0.15.51.64${maven.build.timestamp}
- 1.3.4
+ 1.3.53.61.81.2.2
diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java
index b88edb5a..25966fa6 100644
--- a/src/main/java/org/qortal/api/ApiService.java
+++ b/src/main/java/org/qortal/api/ApiService.java
@@ -43,6 +43,8 @@ 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.TradeBotWebSocket;
+import org.qortal.api.websocket.TradeOffersWebSocket;
import org.qortal.settings.Settings;
public class ApiService {
@@ -196,6 +198,8 @@ 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");
// 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
index 5e95e36c..074fd24d 100644
--- a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java
@@ -25,6 +25,9 @@ public class CrossChainBitcoinRedeemRequest {
@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/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java
index c4fa097a..e8d38703 100644
--- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java
@@ -12,20 +12,19 @@ public class CrossChainBuildRequest {
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
- @Schema(description = "Initial QORT amount paid when trade agreed", example = "0.00100000")
- @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
- public long initialQortAmount;
-
@Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
- public long finalQortAmount;
+ 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[] secretHash;
+ public byte[] hashOfSecretB;
@Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java
index e1f57a7e..25a18952 100644
--- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java
@@ -8,10 +8,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainCancelRequest {
- @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
+ @Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
- @Schema(description = "Qortal AT address")
+ @Schema(description = "Qortal trade AT address")
public String atAddress;
public CrossChainCancelRequest() {
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..4cabfc37
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java
@@ -0,0 +1,86 @@
+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.BTCACCT;
+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")
+ public String qortalAtAddress;
+
+ @Schema(description = "AT creator's Qortal address")
+ public String qortalCreator;
+
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ private long qortAmount;
+
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ private long btcAmount;
+
+ @Schema(description = "Suggested trade timeout (minutes)", example = "10080")
+ private int tradeTimeout;
+
+ private BTCACCT.Mode mode;
+
+ private long timestamp;
+
+ private String partnerQortalReceivingAddress;
+
+ protected CrossChainOfferSummary() {
+ /* For JAXB */
+ }
+
+ public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
+ this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
+ this.qortalCreator = crossChainTradeData.qortalCreator;
+ this.qortAmount = crossChainTradeData.qortAmount;
+ this.btcAmount = crossChainTradeData.expectedBitcoin;
+ this.tradeTimeout = crossChainTradeData.tradeTimeout;
+ this.mode = crossChainTradeData.mode;
+ this.timestamp = timestamp;
+ this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
+ }
+
+ public String getQortalAtAddress() {
+ return this.qortalAtAddress;
+ }
+
+ public String getQortalCreator() {
+ return this.qortalCreator;
+ }
+
+ public long getQortAmount() {
+ return this.qortAmount;
+ }
+
+ public long getBtcAmount() {
+ return this.btcAmount;
+ }
+
+ public int getTradeTimeout() {
+ return this.tradeTimeout;
+ }
+
+ public BTCACCT.Mode getMode() {
+ return this.mode;
+ }
+
+ public long getTimestamp() {
+ return this.timestamp;
+ }
+
+ public String getPartnerQortalReceivingAddress() {
+ return this.partnerQortalReceivingAddress;
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
index 99820022..7ad825d4 100644
--- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
@@ -8,14 +8,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainSecretRequest {
- @Schema(description = "Public key to match AT's 'recipient'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
- public byte[] recipientPublicKey;
+ @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 = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
- public byte[] secret;
+ @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 CrossChainSecretRequest() {
}
diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java
index 32737dd5..1afd7290 100644
--- a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java
@@ -8,14 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeRequest {
- @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
- public byte[] creatorPublicKey;
+ @Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
+ public byte[] tradePublicKey;
@Schema(description = "Qortal AT address")
public String atAddress;
- @Schema(description = "Qortal address for trade partner/recipient")
- public String recipient;
+ @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..52ac7de3
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java
@@ -0,0 +1,43 @@
+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;
+
+// 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;
+
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ private long btcAmount;
+
+ protected CrossChainTradeSummary() {
+ /* For JAXB */
+ }
+
+ public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
+ this.tradeTimestamp = timestamp;
+ this.qortAmount = crossChainTradeData.qortAmount;
+ this.btcAmount = crossChainTradeData.expectedBitcoin;
+ }
+
+ public long getTradeTimestamp() {
+ return this.tradeTimestamp;
+ }
+
+ public long getQortAmount() {
+ return this.qortAmount;
+ }
+
+ public long getBtcAmount() {
+ return this.btcAmount;
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java
new file mode 100644
index 00000000..adc319e3
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java
@@ -0,0 +1,36 @@
+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 TradeBotCreateRequest {
+
+ @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
+ public byte[] creatorPublicKey;
+
+ @Schema(description = "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 = "81")
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ public long fundingQortAmount;
+
+ @Schema(description = "Bitcoin amount wanted in return", example = "0.00864200")
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ public long bitcoinAmount;
+
+ @Schema(description = "Suggested trade timeout (minutes)", example = "10080")
+ public int tradeTimeout;
+
+ @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx")
+ public String receivingAddress;
+
+ public TradeBotCreateRequest() {
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java
new file mode 100644
index 00000000..4e947a9b
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.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 TradeBotRespondRequest {
+
+ @Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy")
+ public String atAddress;
+
+ @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
+ public String xprv58;
+
+ @Schema(description = "Qortal address for receiving QORT from AT")
+ public String receivingAddress;
+
+ public TradeBotRespondRequest() {
+ }
+
+}
diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java
index f60deb23..6901af06 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java
@@ -13,6 +13,9 @@ import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.Random;
+import java.util.function.Function;
+import java.util.function.ToIntFunction;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.DELETE;
@@ -24,31 +27,40 @@ import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.bitcoinj.core.Address;
+import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.ECKey;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.core.TransactionOutput;
+import org.bitcoinj.script.Script.ScriptType;
+import org.qortal.account.Account;
import org.qortal.account.PublicKeyAccount;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrors;
-import org.qortal.api.ApiException;
import org.qortal.api.ApiExceptionFactory;
+import org.qortal.api.Security;
import org.qortal.api.model.CrossChainCancelRequest;
import org.qortal.api.model.CrossChainSecretRequest;
import org.qortal.api.model.CrossChainTradeRequest;
+import org.qortal.api.model.CrossChainTradeSummary;
+import org.qortal.api.model.TradeBotCreateRequest;
+import org.qortal.api.model.TradeBotRespondRequest;
import org.qortal.api.model.CrossChainBitcoinP2SHStatus;
import org.qortal.api.model.CrossChainBitcoinRedeemRequest;
import org.qortal.api.model.CrossChainBitcoinRefundRequest;
import org.qortal.api.model.CrossChainBitcoinTemplateRequest;
import org.qortal.api.model.CrossChainBuildRequest;
import org.qortal.asset.Asset;
+import org.qortal.controller.TradeBot;
import org.qortal.crosschain.BTC;
import org.qortal.crosschain.BTCACCT;
+import org.qortal.crosschain.BTCP2SH;
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.crosschain.CrossChainTradeData.Mode;
+import org.qortal.data.crosschain.TradeBotData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
@@ -60,6 +72,7 @@ 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;
@@ -68,8 +81,6 @@ import org.qortal.transform.transaction.MessageTransactionTransformer;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
-import com.google.common.primitives.Bytes;
-
@Path("/crosschain")
@Tag(name = "Cross-Chain")
public class CrossChainResource {
@@ -83,7 +94,6 @@ public class CrossChainResource {
summary = "Find cross-chain trade offers",
responses = {
@ApiResponse(
- description = "automated transactions",
content = @Content(
array = @ArraySchema(
schema = @Schema(
@@ -116,8 +126,6 @@ public class CrossChainResource {
}
return crossChainTradesData;
- } catch (ApiException e) {
- throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -145,12 +153,14 @@ public class CrossChainResource {
)
@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.secretHash == null || tradeRequest.secretHash.length != BTC.HASH160_LENGTH)
+ if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != BTC.HASH160_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.tradeTimeout == null)
@@ -159,17 +169,14 @@ public class CrossChainResource {
if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
- if (tradeRequest.initialQortAmount < 0)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
-
- if (tradeRequest.finalQortAmount <= 0)
+ 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.initialQortAmount + tradeRequest.finalQortAmount)
+ if (tradeRequest.fundingQortAmount <= tradeRequest.qortAmount)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (tradeRequest.bitcoinAmount <= 0)
@@ -178,7 +185,8 @@ public class CrossChainResource {
try (final Repository repository = RepositoryManager.getRepository()) {
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
- byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.secretHash, tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount);
+ byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB,
+ tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout);
long txTimestamp = NTP.getTime();
byte[] lastReference = creatorAccount.getLastReference();
@@ -213,12 +221,12 @@ public class CrossChainResource {
}
@POST
- @Path("/tradeoffer/recipient")
+ @Path("/tradeoffer/trademessage")
@Operation(
- summary = "Builds raw, unsigned MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode",
- description = "Specify address of cross-chain AT that needs to be messaged, and address of Qortal recipient. "
- + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send! "
- + "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
+ 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(
@@ -239,29 +247,55 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
- public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) {
- byte[] creatorPublicKey = tradeRequest.creatorPublicKey;
+ public String buildTradeMessage(CrossChainTradeRequest tradeRequest) {
+ Security.checkApiCallAllowed(request);
- if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
+ 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.recipient == null || !Crypto.isValidAddress(tradeRequest.recipient))
+ 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, creatorPublicKey, tradeRequest.atAddress);
+ ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
+ if (crossChainTradeData.mode != BTCACCT.Mode.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();
+ BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData);
+ if (offerMessageData == null)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
+
// Good to make MESSAGE
- byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0);
- byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes);
+ byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH;
+ byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
+ int lockTimeA = (int) offerMessageData.lockTimeA;
+
+ String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
+ int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
+
+ byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
+ byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
@@ -270,12 +304,12 @@ public class CrossChainResource {
}
@POST
- @Path("/tradeoffer/secret")
+ @Path("/tradeoffer/redeemmessage")
@Operation(
- summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient",
- description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret. "
- + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send! "
- + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.",
+ 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(
@@ -296,35 +330,43 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
- public String sendSecret(CrossChainSecretRequest secretRequest) {
- byte[] recipientPublicKey = secretRequest.recipientPublicKey;
+ public String buildRedeemMessage(CrossChainSecretRequest secretRequest) {
+ Security.checkApiCallAllowed(request);
- if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
+ 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.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH)
+ if (secretRequest.secretA == null || secretRequest.secretA.length != BTCACCT.SECRET_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
+ if (secretRequest.secretB == null || secretRequest.secretB.length != BTCACCT.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, null, secretRequest.atAddress); // null to skip creator check
+ ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER)
+ if (crossChainTradeData.mode != BTCACCT.Mode.TRADING)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey);
- String recipientAddress = recipientAccount.getAddress();
+ String partnerAddress = Crypto.toAddress(partnerPublicKey);
- // MESSAGE must come from address that AT considers trade partner / 'recipient'
- if (!crossChainTradeData.qortalRecipient.equals(recipientAddress))
+ // 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[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret);
+ byte[] messageData = BTCACCT.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress);
+ byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
@@ -335,10 +377,10 @@ public class CrossChainResource {
@DELETE
@Path("/tradeoffer")
@Operation(
- summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer",
+ 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, but still cost fees to send! "
- + "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.",
+ + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -359,7 +401,9 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
- public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) {
+ public String buildCancelMessage(CrossChainCancelRequest cancelRequest) {
+ Security.checkApiCallAllowed(request);
+
byte[] creatorPublicKey = cancelRequest.creatorPublicKey;
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
@@ -369,19 +413,22 @@ public class CrossChainResource {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
- ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress);
+ ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE)
+ if (crossChainTradeData.mode != BTCACCT.Mode.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
- PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
- String creatorAddress = creatorAccount.getAddress();
- byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0);
+ String atCreatorAddress = Crypto.toAddress(creatorPublicKey);
+ byte[] messageData = BTCACCT.buildCancelMessage(atCreatorAddress);
- byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes);
+ byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
@@ -390,9 +437,9 @@ public class CrossChainResource {
}
@POST
- @Path("/p2sh")
+ @Path("/p2sh/a")
@Operation(
- summary = "Returns Bitcoin P2SH address based on trade info",
+ summary = "Returns Bitcoin P2SH-A address based on trade info",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -409,7 +456,39 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
- public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) {
+ public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
+ }
+
+ @POST
+ @Path("/p2sh/b")
+ @Operation(
+ summary = "Returns Bitcoin P2SH-B address based on trade info",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = CrossChainBitcoinTemplateRequest.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
+ public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
+ }
+
+ private String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) {
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
@@ -424,13 +503,13 @@ public class CrossChainResource {
// Extract data from cross-chain trading AT
try (final Repository repository = RepositoryManager.getRepository()) {
- ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check
+ ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == Mode.OFFER)
+ if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData));
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
@@ -441,9 +520,9 @@ public class CrossChainResource {
}
@POST
- @Path("/p2sh/check")
+ @Path("/p2sh/a/check")
@Operation(
- summary = "Checks Bitcoin P2SH address based on trade info",
+ summary = "Checks Bitcoin P2SH-A address based on trade info",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -460,7 +539,39 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
- public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) {
+ public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
+ }
+
+ @POST
+ @Path("/p2sh/b/check")
+ @Operation(
+ summary = "Checks Bitcoin P2SH-B address based on trade info",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = CrossChainBitcoinTemplateRequest.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
+ public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
+ }
+
+ private CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) {
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
@@ -475,13 +586,16 @@ public class CrossChainResource {
// Extract data from cross-chain trading AT
try (final Repository repository = RepositoryManager.getRepository()) {
- ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check
+ ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == Mode.OFFER)
+ if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
+ int lockTime = lockTimeFn.applyAsInt(crossChainTradeData);
+ byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData);
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret);
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
@@ -494,25 +608,25 @@ public class CrossChainResource {
// Check P2SH is funded
- Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
+ Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
if (p2shBalance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus();
p2shStatus.bitcoinP2shAddress = p2shAddress.toString();
- p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8);
+ p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8);
List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
- if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) {
+ if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) {
p2shStatus.canRedeem = now >= medianBlockTime * 1000L;
- p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L;
+ p2shStatus.canRefund = now >= lockTime * 1000L;
}
if (now >= medianBlockTime * 1000L) {
// See if we can extract secret
List rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress);
- p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions);
+ p2shStatus.secret = BTCP2SH.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions);
}
return p2shStatus;
@@ -522,9 +636,9 @@ public class CrossChainResource {
}
@POST
- @Path("/p2sh/refund")
+ @Path("/p2sh/a/refund")
@Operation(
- summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address",
+ summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -542,7 +656,40 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
- public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) {
+ public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
+ }
+
+ @POST
+ @Path("/p2sh/b/refund")
+ @Operation(
+ summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-B address",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = CrossChainBitcoinRefundRequest.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
+ ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
+ public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
+ }
+
+ private String refundP2sh(CrossChainBitcoinRefundRequest refundRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) {
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
@@ -572,13 +719,16 @@ public class CrossChainResource {
// Extract data from cross-chain trading AT
try (final Repository repository = RepositoryManager.getRepository()) {
- ATData atData = fetchAtDataWithChecking(repository, null, refundRequest.atAddress); // null to skip creator check
+ ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == Mode.OFFER)
+ if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- byte[] redeemScriptBytes = BTCACCT.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash);
+ int lockTime = lockTimeFn.applyAsInt(crossChainTradeData);
+ byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData);
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret);
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
@@ -587,7 +737,7 @@ public class CrossChainResource {
// Check P2SH is funded
- Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
+ Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
if (p2shBalance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
@@ -595,16 +745,16 @@ public class CrossChainResource {
if (fundingOutputs.isEmpty())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
- boolean canRefund = now >= crossChainTradeData.lockTime * 1000L;
+ boolean canRefund = now >= lockTime * 1000L;
if (!canRefund)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON);
- if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
+ if (p2shBalance < crossChainTradeData.expectedBitcoin)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE);
- Coin refundAmount = p2shBalance.subtract(Coin.valueOf(refundRequest.bitcoinMinerFee.unscaledValue().longValue()));
+ Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue());
- org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime);
+ org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime);
boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction);
if (!wasBroadcast)
@@ -617,9 +767,10 @@ public class CrossChainResource {
}
@POST
- @Path("/p2sh/redeem")
+ @Path("/p2sh/a/redeem")
@Operation(
- summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address",
+ summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address",
+ description = "Secret payload needs to be secret-A (64 bytes)",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -637,7 +788,41 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
- public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) {
+ public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
+ }
+
+ @POST
+ @Path("/p2sh/b/redeem")
+ @Operation(
+ summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address",
+ description = "Secret payload needs to be secret-B (32 bytes)",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(
+ implementation = CrossChainBitcoinRedeemRequest.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN,
+ ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
+ public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) {
+ Security.checkApiCallAllowed(request);
+
+ return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
+ }
+
+ private String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) {
BTC btc = BTC.getInstance();
NetworkParameters params = btc.getNetworkParameters();
@@ -668,15 +853,24 @@ public class CrossChainResource {
if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
+ if (redeemRequest.receivingAccountInfo == null)
+ redeemRequest.receivingAccountInfo = redeemKey.getPubKeyHash();
+
+ if (redeemRequest.receivingAccountInfo.length != 20)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
+
// Extract data from cross-chain trading AT
try (final Repository repository = RepositoryManager.getRepository()) {
- ATData atData = fetchAtDataWithChecking(repository, null, redeemRequest.atAddress); // null to skip creator check
+ ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress);
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- if (crossChainTradeData.mode == Mode.OFFER)
+ if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- byte[] redeemScriptBytes = BTCACCT.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash);
+ int lockTime = lockTimeFn.applyAsInt(crossChainTradeData);
+ byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData);
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret);
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
@@ -688,11 +882,11 @@ public class CrossChainResource {
long now = NTP.getTime();
// Check P2SH is funded
- Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
+ Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString());
if (p2shBalance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
- if (p2shBalance.value < crossChainTradeData.expectedBitcoin)
+ if (p2shBalance < crossChainTradeData.expectedBitcoin)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE);
List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
@@ -703,9 +897,9 @@ public class CrossChainResource {
if (!canRedeem)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON);
- Coin redeemAmount = p2shBalance.subtract(Coin.valueOf(redeemRequest.bitcoinMinerFee.unscaledValue().longValue()));
+ Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue());
- org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret);
+ org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo);
boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction);
if (!wasBroadcast)
@@ -717,15 +911,305 @@ public class CrossChainResource {
}
}
- private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException {
+ @POST
+ @Path("/btc/walletbalance")
+ @Operation(
+ summary = "Returns BTC balance for BIP32 wallet",
+ description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ description = "BIP32 'm' private key in base58",
+ example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ"
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_PRIVATE_KEY})
+ public String getBitcoinWalletBalance(String xprv58) {
+ Security.checkApiCallAllowed(request);
+
+ if (!BTC.getInstance().isValidXprv(xprv58))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+
+ Long balance = BTC.getInstance().getWalletBalance(xprv58);
+ if (balance == null)
+ return "null";
+
+ return balance.toString();
+ }
+
+ @GET
+ @Path("/tradebot")
+ @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() {
+ Security.checkApiCallAllowed(request);
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ return repository.getCrossChainRepository().getAllTradeBotData();
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @POST
+ @Path("/tradebot/create")
+ @Operation(
+ summary = "Create a trade offer",
+ 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.REPOSITORY_ISSUE})
+ public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
+ Security.checkApiCallAllowed(request);
+
+ Address receivingAddress;
+ try {
+ receivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
+ } catch (AddressFormatException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+ }
+
+ // We only support P2PKH addresses at this time
+ if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+
+ if (tradeBotCreateRequest.tradeTimeout < 60)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ if (tradeBotCreateRequest.bitcoinAmount <= 0 || 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 TransactionsResource.createTransactionInvalidException(request, ValidationResult.NO_BALANCE);
+
+ byte[] unsignedBytes = TradeBot.createTrade(repository, tradeBotCreateRequest);
+
+ return Base58.encode(unsignedBytes);
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @POST
+ @Path("/tradebot/respond")
+ @Operation(
+ summary = "Respond to a trade offer (WILL SPEND BITCOIN!)",
+ description = "Start a new trade-bot entry to respond to chosen trade offer. Trade-bot starts by funding Bitcoin side of trade!",
+ 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_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
+ public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) {
+ Security.checkApiCallAllowed(request);
+
+ final String atAddress = tradeBotRespondRequest.atAddress;
+
+ if (atAddress == null || !Crypto.isValidAtAddress(atAddress))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+
+ if (!BTC.getInstance().isValidXprv(tradeBotRespondRequest.xprv58))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+
+ 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);
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
+
+ TradeBot.ResponseResult result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress);
+
+ switch (result) {
+ case OK:
+ return "true";
+
+ case INSUFFICIENT_FUNDS:
+ case BTC_BALANCE_ISSUE:
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE);
+
+ case BTC_NETWORK_ISSUE:
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
+
+ default:
+ return "false";
+ }
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
+ @DELETE
+ @Path("/tradebot/trade")
+ @Operation(
+ summary = "Delete completed trade",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "string",
+ example = "Au6kioR6XT2CPxT6qsyQ1WjS9zNYg7tpwSrFeVqCDdMR"
+ )
+ )
+ ),
+ 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()) {
+ TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey);
+ if (tradeBotData == null)
+ return "false";
+
+ switch (tradeBotData.getState()) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ case ALICE_DONE:
+ case BOB_DONE:
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ break;
+
+ default:
+ return "false";
+ }
+
+ repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
+ repository.saveChanges();
+
+ return "true";
+ } 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( 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 isFinished = Boolean.TRUE;
+ final Integer minimumFinalHeight = null;
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
+ isFinished,
+ BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value,
+ minimumFinalHeight,
+ limit, offset, reverse);
+
+ List crossChainTrades = new ArrayList<>();
+ for (ATStateData atState : atStates) {
+ CrossChainTradeData crossChainTradeData = BTCACCT.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);
+ }
+ }
+
+ 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);
- // Does supplied public key match that of AT?
- if (creatorPublicKey != null && !Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey()))
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
-
// Must be correct AT - check functionality using code hash
if (!Arrays.equals(atData.getCodeHash(), BTCACCT.CODE_BYTES_HASH))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
@@ -738,27 +1222,36 @@ public class CrossChainResource {
}
private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException {
- PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, senderPublicKey);
-
long txTimestamp = NTP.getTime();
- byte[] lastReference = creatorAccount.getLastReference();
- if (lastReference == null)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
+ // 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 = null;
+ 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);
- fee = messageTransaction.calcRecommendedFee();
- messageTransactionData.setFee(fee);
+ if (requiresPoW) {
+ messageTransaction.computeNonce();
+ } else {
+ fee = messageTransaction.calcRecommendedFee();
+ messageTransactionData.setFee(fee);
+ }
ValidationResult result = messageTransaction.isValidUnconfirmed();
if (result != ValidationResult.OK)
diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java
index a276dba7..1f541e36 100644
--- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java
+++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java
@@ -12,7 +12,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.controller.ChatNotifier;
import org.qortal.crypto.Crypto;
@@ -24,7 +23,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
-public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSocket {
+public class ActiveChatsWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -33,7 +32,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
@OnWebSocketConnect
public void onWebSocketConnect(Session session) {
- Map pathParams = this.getPathParams(session, "/{address}");
+ Map pathParams = getPathParams(session, "/{address}");
String address = pathParams.get("address");
if (address == null || !Crypto.isValidAddress(address)) {
@@ -76,7 +75,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
StringWriter stringWriter = new StringWriter();
- this.marshall(stringWriter, activeChats);
+ marshall(stringWriter, activeChats);
// Only output if something has changed
String output = stringWriter.toString();
diff --git a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java
index ff42be2e..173a3abf 100644
--- a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java
+++ b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java
@@ -11,7 +11,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.api.model.NodeStatus;
import org.qortal.controller.StatusNotifier;
@@ -21,7 +20,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
-public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSocket {
+public class AdminStatusWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -57,7 +56,7 @@ public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSock
StringWriter stringWriter = new StringWriter();
- this.marshall(stringWriter, nodeStatus);
+ marshall(stringWriter, nodeStatus);
// Only output if something has changed
String output = stringWriter.toString();
diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java
index 9209c5b9..87ee16cd 100644
--- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java
+++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java
@@ -3,7 +3,10 @@ package org.qortal.api.websocket;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
+import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import javax.xml.bind.JAXBContext;
@@ -13,24 +16,28 @@ import javax.xml.bind.Marshaller;
import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
+import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
import org.eclipse.persistence.jaxb.JAXBContextFactory;
import org.eclipse.persistence.jaxb.MarshallerProperties;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrorRoot;
-interface ApiWebSocket {
+@SuppressWarnings("serial")
+abstract class ApiWebSocket extends WebSocketServlet {
- default String getPathInfo(Session session) {
+ private static final Map, List> SESSIONS_BY_CLASS = new HashMap<>();
+
+ protected static String getPathInfo(Session session) {
ServletUpgradeRequest upgradeRequest = (ServletUpgradeRequest) session.getUpgradeRequest();
return upgradeRequest.getHttpServletRequest().getPathInfo();
}
- default Map getPathParams(Session session, String pathSpec) {
+ protected static Map getPathParams(Session session, String pathSpec) {
UriTemplatePathSpec uriTemplatePathSpec = new UriTemplatePathSpec(pathSpec);
- return uriTemplatePathSpec.getPathParams(this.getPathInfo(session));
+ return uriTemplatePathSpec.getPathParams(getPathInfo(session));
}
- default void sendError(Session session, ApiError apiError) {
+ protected static void sendError(Session session, ApiError apiError) {
ApiErrorRoot apiErrorRoot = new ApiErrorRoot();
apiErrorRoot.setApiError(apiError);
@@ -43,7 +50,7 @@ interface ApiWebSocket {
}
}
- default void marshall(Writer writer, Object object) throws IOException {
+ protected static void marshall(Writer writer, Object object) throws IOException {
Marshaller marshaller = createMarshaller(object.getClass());
try {
@@ -53,7 +60,7 @@ interface ApiWebSocket {
}
}
- default void marshall(Writer writer, Collection> collection) throws IOException {
+ protected static void marshall(Writer writer, Collection> collection) throws IOException {
// If collection is empty then we're returning "[]" anyway
if (collection.isEmpty()) {
writer.append("[]");
@@ -92,4 +99,22 @@ interface ApiWebSocket {
}
}
+ public void onWebSocketConnect(Session session) {
+ synchronized (SESSIONS_BY_CLASS) {
+ SESSIONS_BY_CLASS.computeIfAbsent(this.getClass(), clazz -> new ArrayList<>()).add(session);
+ }
+ }
+
+ public void onWebSocketClose(Session session, int statusCode, String reason) {
+ synchronized (SESSIONS_BY_CLASS) {
+ SESSIONS_BY_CLASS.get(this.getClass()).remove(session);
+ }
+ }
+
+ protected List getSessions() {
+ synchronized (SESSIONS_BY_CLASS) {
+ return new ArrayList<>(SESSIONS_BY_CLASS.get(this.getClass()));
+ }
+ }
+
}
diff --git a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java
index cc5a0fbf..46a5fd84 100644
--- a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java
+++ b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java
@@ -11,7 +11,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.api.ApiError;
import org.qortal.api.model.BlockInfo;
@@ -23,7 +22,7 @@ import org.qortal.utils.Base58;
@WebSocket
@SuppressWarnings("serial")
-public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
+public class BlocksWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -111,7 +110,7 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
StringWriter stringWriter = new StringWriter();
try {
- this.marshall(stringWriter, blockInfo);
+ marshall(stringWriter, blockInfo);
session.getRemote().sendStringByFuture(stringWriter.toString());
} catch (IOException | WebSocketException e) {
diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java
index c7244cda..beaa9ad5 100644
--- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java
+++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java
@@ -14,7 +14,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.controller.ChatNotifier;
import org.qortal.data.chat.ChatMessage;
@@ -25,7 +24,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
-public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSocket {
+public class ChatMessagesWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -129,7 +128,7 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc
StringWriter stringWriter = new StringWriter();
try {
- this.marshall(stringWriter, chatMessages);
+ marshall(stringWriter, chatMessages);
session.getRemote().sendStringByFuture(stringWriter.toString());
} catch (IOException | WebSocketException e) {
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..e97e54bc
--- /dev/null
+++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java
@@ -0,0 +1,119 @@
+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.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.qortal.controller.TradeBot;
+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<>();
+
+ @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::getState)));
+ } 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) {
+ if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState())
+ // Not changed
+ return;
+
+ PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState());
+ }
+
+ List tradeBotEntries = Collections.singletonList(tradeBotData);
+ for (Session session : getSessions())
+ sendEntries(session, tradeBotEntries);
+ }
+
+ @OnWebSocketConnect
+ public void onWebSocketConnect(Session session) {
+ // Send all known trade-bot entries
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
+ if (tradeBotEntries == null) {
+ session.close(4001, "repository issue fetching trade-bot entries");
+ return;
+ }
+
+ if (!sendEntries(session, tradeBotEntries)) {
+ session.close(4002, "websocket issue");
+ return;
+ }
+ } catch (DataException e) {
+ // No output this time
+ }
+
+ super.onWebSocketConnect(session);
+ }
+
+ @OnWebSocketClose
+ public void onWebSocketClose(Session session, int statusCode, String reason) {
+ super.onWebSocketClose(session, statusCode, reason);
+ }
+
+ @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..2520b1d8
--- /dev/null
+++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java
@@ -0,0 +1,212 @@
+package org.qortal.api.websocket;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.ArrayList;
+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.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.BlockNotifier;
+import org.qortal.crosschain.BTCACCT;
+import org.qortal.data.at.ATStateData;
+import org.qortal.data.block.BlockData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.repository.RepositoryManager;
+import org.qortal.utils.NTP;
+
+@WebSocket
+@SuppressWarnings("serial")
+public class TradeOffersWebSocket extends ApiWebSocket {
+
+ @Override
+ public void configure(WebSocketServletFactory factory) {
+ factory.register(TradeOffersWebSocket.class);
+ }
+
+ @OnWebSocketConnect
+ public void onWebSocketConnect(Session session) {
+ Map> queryParams = session.getUpgradeRequest().getParameterMap();
+
+ final boolean includeHistoric = queryParams.get("includeHistoric") != null;
+ final Map previousAtModes = new HashMap<>();
+ List crossChainOfferSummaries;
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List initialAtStates;
+
+ // We want ALL OFFERING trades
+ Boolean isFinished = Boolean.FALSE;
+ Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET;
+ Long expectedValue = (long) BTCACCT.Mode.OFFERING.value;
+ Integer minimumFinalHeight = null;
+
+ initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
+ isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
+ null, null, null);
+
+ if (initialAtStates == null) {
+ session.close(4001, "repository issue fetching OFFERING trades");
+ return;
+ }
+
+ // Save initial AT modes
+ previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING)));
+
+ // Convert to offer summaries
+ crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null);
+
+ if (includeHistoric) {
+ // We also want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours
+ long timestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
+ minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
+
+ if (minimumFinalHeight != 0) {
+ isFinished = Boolean.TRUE;
+ dataByteOffset = null;
+ expectedValue = null;
+ ++minimumFinalHeight; // because height is just *before* timestamp
+
+ List historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
+ isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
+ null, null, null);
+
+ if (historicAtStates == null) {
+ session.close(4002, "repository issue fetching historic trades");
+ return;
+ }
+
+ for (ATStateData historicAtState : historicAtStates) {
+ CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null);
+
+ switch (historicOfferSummary.getMode()) {
+ case REDEEMED:
+ case REFUNDED:
+ case CANCELLED:
+ break;
+
+ default:
+ continue;
+ }
+
+ // Add summary to initial burst
+ crossChainOfferSummaries.add(historicOfferSummary);
+
+ // Save initial AT mode
+ previousAtModes.put(historicAtState.getATAddress(), historicOfferSummary.getMode());
+ }
+ }
+ }
+
+ } catch (DataException e) {
+ session.close(4003, "generic repository issue");
+ return;
+ }
+
+ if (!sendOfferSummaries(session, crossChainOfferSummaries)) {
+ session.close(4004, "websocket issue");
+ return;
+ }
+
+ BlockNotifier.Listener listener = blockData -> onNotify(session, blockData, previousAtModes);
+ BlockNotifier.getInstance().register(session, listener);
+ }
+
+ @OnWebSocketClose
+ public void onWebSocketClose(Session session, int statusCode, String reason) {
+ BlockNotifier.getInstance().deregister(session);
+ }
+
+ @OnWebSocketMessage
+ public void onWebSocketMessage(Session session, String message) {
+ /* ignored */
+ }
+
+ private void onNotify(Session session, BlockData blockData, final Map previousAtModes) {
+ List crossChainOfferSummaries = null;
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ // Find any new trade ATs since this block
+ final Boolean isFinished = null;
+ final Integer dataByteOffset = null;
+ final Long expectedValue = null;
+ final Integer minimumFinalHeight = blockData.getHeight();
+
+ List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
+ isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
+ null, null, null);
+
+ if (atStates == null)
+ return;
+
+ crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp());
+ } catch (DataException e) {
+ // No output this time
+ }
+
+ synchronized (previousAtModes) { //NOSONAR squid:S2445 suppressed because previousAtModes is final and curried in lambda
+ // Remove any entries unchanged from last time
+ crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode());
+
+ // Don't send anything if no results
+ if (crossChainOfferSummaries.isEmpty())
+ return;
+
+ final boolean wasSent = sendOfferSummaries(session, crossChainOfferSummaries);
+
+ if (!wasSent)
+ return;
+
+ previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode)));
+ }
+ }
+
+ 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 CrossChainOfferSummary produceSummary(Repository repository, ATStateData atState, Long timestamp) throws DataException {
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState);
+
+ long atStateTimestamp;
+
+ if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING)
+ // We want when trade was created, not when it was last updated
+ atStateTimestamp = atState.getCreation();
+ else
+ atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight());
+
+ return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp);
+ }
+
+ private static List produceSummaries(Repository repository, List atStates, Long timestamp) throws DataException {
+ List offerSummaries = new ArrayList<>();
+
+ for (ATStateData atState : atStates)
+ offerSummaries.add(produceSummary(repository, atState, timestamp));
+
+ return offerSummaries;
+ }
+
+}
diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java
index bf7d2abc..582b44e2 100644
--- a/src/main/java/org/qortal/at/QortalATAPI.java
+++ b/src/main/java/org/qortal/at/QortalATAPI.java
@@ -17,7 +17,6 @@ import org.qortal.account.Account;
import org.qortal.account.NullAccount;
import org.qortal.account.PublicKeyAccount;
import org.qortal.asset.Asset;
-import org.qortal.block.Block;
import org.qortal.block.BlockChain;
import org.qortal.block.BlockChain.CiyamAtSettings;
import org.qortal.crypto.Crypto;
@@ -30,13 +29,13 @@ import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.PaymentTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
-import org.qortal.repository.BlockRepository;
+import org.qortal.repository.ATRepository;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.transaction.AtTransaction;
-import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.TransactionType;
import org.qortal.utils.Base58;
+import org.qortal.utils.BitTwiddling;
import com.google.common.primitives.Bytes;
@@ -133,9 +132,9 @@ public class QortalATAPI extends API {
byte[] signature = blockSummaries.get(0).getSignature();
// Save some of minter's signature and transactions signature, so middle 24 bytes of the full 128 byte signature.
- this.setA2(state, fromBytes(signature, 52));
- this.setA3(state, fromBytes(signature, 60));
- this.setA4(state, fromBytes(signature, 68));
+ this.setA2(state, BitTwiddling.longFromBEBytes(signature, 52));
+ this.setA3(state, BitTwiddling.longFromBEBytes(signature, 60));
+ this.setA4(state, BitTwiddling.longFromBEBytes(signature, 68));
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch previous block?", e);
}
@@ -149,59 +148,27 @@ public class QortalATAPI extends API {
int height = timestamp.blockHeight;
int sequence = timestamp.transactionSequence + 1;
- BlockRepository blockRepository = this.getRepository().getBlockRepository();
-
+ ATRepository.NextTransactionInfo nextTransactionInfo;
try {
- int currentHeight = blockRepository.getBlockchainHeight();
- List blockTransactions = null;
-
- while (height <= currentHeight) {
- if (blockTransactions == null) {
- BlockData blockData = blockRepository.fromHeight(height);
-
- if (blockData == null)
- throw new DataException("Unable to fetch block " + height + " from repository?");
-
- Block block = new Block(this.getRepository(), blockData);
-
- blockTransactions = block.getTransactions();
- }
-
- // No more transactions in this block? Try next block
- if (sequence >= blockTransactions.size()) {
- ++height;
- sequence = 0;
- blockTransactions = null;
- continue;
- }
-
- Transaction transaction = blockTransactions.get(sequence);
-
- // Transaction needs to be sent to specified recipient
- List recipientAddresses = transaction.getRecipientAddresses();
- if (recipientAddresses.contains(atAddress)) {
- // Found a transaction
-
- this.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
-
- // Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
- byte[] signature = transaction.getTransactionData().getSignature();
- this.setA2(state, fromBytes(signature, 8));
- this.setA3(state, fromBytes(signature, 16));
- this.setA4(state, fromBytes(signature, 24));
-
- return;
- }
-
- // Transaction wasn't for us - keep going
- ++sequence;
- }
-
- // No more transactions - zero A and exit
- this.zeroA(state);
+ nextTransactionInfo = this.getRepository().getATRepository().findNextTransaction(atAddress, height, sequence);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch next transaction?", e);
}
+
+ if (nextTransactionInfo == null) {
+ // No more transactions for AT at this time - zero A and exit
+ this.zeroA(state);
+ return;
+ }
+
+ // Found a transaction
+
+ this.setA1(state, new Timestamp(nextTransactionInfo.height, timestamp.blockchainId, nextTransactionInfo.sequence).longValue());
+
+ // Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
+ this.setA2(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 8));
+ this.setA3(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 16));
+ this.setA4(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 24));
}
@Override
@@ -282,7 +249,7 @@ public class QortalATAPI extends API {
byte[] hash = Crypto.digest(input);
- return fromBytes(hash, 0);
+ return BitTwiddling.longFromBEBytes(hash, 0);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch latest block from repository?", e);
}
@@ -296,30 +263,14 @@ public class QortalATAPI extends API {
TransactionData transactionData = this.getTransactionFromA(state);
- byte[] messageData = null;
-
- switch (transactionData.getType()) {
- case MESSAGE:
- messageData = ((MessageTransactionData) transactionData).getData();
- break;
-
- case AT:
- messageData = ((ATTransactionData) transactionData).getMessage();
- break;
-
- default:
- return;
- }
-
- // Check data length is appropriate, i.e. not larger than B
- if (messageData.length > 4 * 8)
- return;
+ byte[] messageData = this.getMessageFromTransaction(transactionData);
// Pad messageData to fit B
- byte[] paddedMessageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
+ if (messageData.length < 4 * 8)
+ messageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
// Endian must be correct here so that (for example) a SHA256 message can be compared to one generated locally
- this.setB(state, paddedMessageData);
+ this.setB(state, messageData);
}
@Override
@@ -457,12 +408,6 @@ public class QortalATAPI extends API {
// Utility methods
- /** Convert part of little-endian byte[] to long */
- /* package */ static long fromBytes(byte[] bytes, int start) {
- return (bytes[start] & 0xffL) | (bytes[start + 1] & 0xffL) << 8 | (bytes[start + 2] & 0xffL) << 16 | (bytes[start + 3] & 0xffL) << 24
- | (bytes[start + 4] & 0xffL) << 32 | (bytes[start + 5] & 0xffL) << 40 | (bytes[start + 6] & 0xffL) << 48 | (bytes[start + 7] & 0xffL) << 56;
- }
-
/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */
public static byte[] partialSignature(byte[] fullSignature) {
return Arrays.copyOfRange(fullSignature, 8, 32);
@@ -473,7 +418,7 @@ public class QortalATAPI extends API {
// Compare end of transaction's signature against A2 thru A4
byte[] sig = transactionData.getSignature();
- if (this.getA2(state) != fromBytes(sig, 8) || this.getA3(state) != fromBytes(sig, 16) || this.getA4(state) != fromBytes(sig, 24))
+ if (this.getA2(state) != BitTwiddling.longFromBEBytes(sig, 8) || this.getA3(state) != BitTwiddling.longFromBEBytes(sig, 16) || this.getA4(state) != BitTwiddling.longFromBEBytes(sig, 24))
throw new IllegalStateException("Transaction signature in A no longer matches signature from repository");
}
@@ -497,6 +442,20 @@ public class QortalATAPI extends API {
}
}
+ /** Returns message data from transaction. */
+ /*package*/ byte[] getMessageFromTransaction(TransactionData transactionData) {
+ switch (transactionData.getType()) {
+ case MESSAGE:
+ return ((MessageTransactionData) transactionData).getData();
+
+ case AT:
+ return ((ATTransactionData) transactionData).getMessage();
+
+ default:
+ return null;
+ }
+ }
+
/** Returns AT's account */
/* package */ Account getATAccount() {
return new Account(this.repository, this.atData.getATAddress());
@@ -563,4 +522,8 @@ public class QortalATAPI extends API {
super.setB(state, bBytes);
}
+ protected void zeroB(MachineState state) {
+ super.zeroB(state);
+ }
+
}
diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java
index cf6b1cfd..67ab5b98 100644
--- a/src/main/java/org/qortal/at/QortalFunctionCode.java
+++ b/src/main/java/org/qortal/at/QortalFunctionCode.java
@@ -12,6 +12,7 @@ import org.ciyam.at.IllegalFunctionCodeException;
import org.ciyam.at.MachineState;
import org.qortal.crosschain.BTC;
import org.qortal.crypto.Crypto;
+import org.qortal.data.transaction.TransactionData;
import org.qortal.settings.Settings;
/**
@@ -22,8 +23,70 @@ import org.qortal.settings.Settings;
*/
public enum QortalFunctionCode {
/**
- * 0x0510
- * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
+ * Returns length of message data from transaction in A.
+ * 0x0501
+ * If transaction has no 'message', returns -1.
+ */
+ GET_MESSAGE_LENGTH_FROM_TX_IN_A(0x0501, 0, true) {
+ @Override
+ protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
+ QortalATAPI api = (QortalATAPI) state.getAPI();
+
+ TransactionData transactionData = api.getTransactionFromA(state);
+
+ byte[] messageData = api.getMessageFromTransaction(transactionData);
+
+ if (messageData == null)
+ functionData.returnValue = -1L;
+ else
+ functionData.returnValue = (long) messageData.length;
+ }
+ },
+ /**
+ * Put offset 'message' from transaction in A into B
+ * 0x0502 start-offset
+ * Copies up to 32 bytes of message data, starting at start-offset into B.
+ * If transaction has no 'message', or start-offset out of bounds, then zero B
+ * Example 'message' could be 256-bit shared secret
+ */
+ PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B(0x0502, 1, false) {
+ @Override
+ protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
+ QortalATAPI api = (QortalATAPI) state.getAPI();
+
+ // In case something goes wrong, or we don't have enough message data.
+ api.zeroB(state);
+
+ if (functionData.value1 < 0 || functionData.value1 > Integer.MAX_VALUE)
+ return;
+
+ int startOffset = functionData.value1.intValue();
+
+ TransactionData transactionData = api.getTransactionFromA(state);
+
+ byte[] messageData = api.getMessageFromTransaction(transactionData);
+
+ if (messageData == null || startOffset > messageData.length)
+ return;
+
+ /*
+ * Copy up to 32 bytes of message data into B,
+ * retain order but pad with zeros in lower bytes.
+ *
+ * So a 4-byte message "a b c d" would copy thusly:
+ * a b c d 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ */
+ int byteCount = Math.min(32, messageData.length - startOffset);
+ byte[] bBytes = new byte[32];
+
+ System.arraycopy(messageData, startOffset, bBytes, 0, byteCount);
+
+ api.setB(state, bBytes);
+ }
+ },
+ /**
+ * Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
+ * 0x0510
*/
CONVERT_B_TO_PKH(0x0510, 0, false) {
@Override
@@ -38,8 +101,8 @@ public enum QortalFunctionCode {
}
},
/**
- * 0x0511
* 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) {
@@ -51,8 +114,8 @@ public enum QortalFunctionCode {
}
},
/**
- * 0x0512
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.
+ * 0x0512
* Qortal address stored in lower 25 bytes of B.
*/
CONVERT_B_TO_QORTAL(0x0512, 0, false) {
diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java
index ce593bb4..fbde811b 100644
--- a/src/main/java/org/qortal/controller/Controller.java
+++ b/src/main/java/org/qortal/controller/Controller.java
@@ -792,6 +792,9 @@ public class Controller extends Thread {
this.notifyGroupMembershipChange = false;
ChatNotifier.getInstance().onGroupMembershipChange();
}
+
+ // Trade-bot might want to perform some actions too
+ TradeBot.getInstance().onChainTipChange();
});
}
diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java
new file mode 100644
index 00000000..496125b4
--- /dev/null
+++ b/src/main/java/org/qortal/controller/TradeBot.java
@@ -0,0 +1,1049 @@
+package org.qortal.controller;
+
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+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.TradeBotCreateRequest;
+import org.qortal.asset.Asset;
+import org.qortal.crosschain.BTC;
+import org.qortal.crosschain.BTCACCT;
+import org.qortal.crosschain.BTCP2SH;
+import org.qortal.crypto.Crypto;
+import org.qortal.data.account.AccountBalanceData;
+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.event.Event;
+import org.qortal.event.EventBus;
+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.ValidationResult;
+import org.qortal.transform.TransformationException;
+import org.qortal.transform.transaction.DeployAtTransactionTransformer;
+import org.qortal.utils.Amounts;
+import org.qortal.utils.Base58;
+import org.qortal.utils.NTP;
+
+public class TradeBot {
+
+ public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE }
+
+ 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 Logger LOGGER = LogManager.getLogger(TradeBot.class);
+ private static final Random RANDOM = new SecureRandom();
+ private static final long FEE_AMOUNT = 1000L;
+
+ private static TradeBot instance;
+
+ /** To help ensure only TradeBot is only active on one thread. */
+ private AtomicBoolean activeFlag = new AtomicBoolean(false);
+
+ private TradeBot() {
+ }
+
+ public static synchronized TradeBot getInstance() {
+ if (instance == null)
+ instance = new TradeBot();
+
+ return instance;
+ }
+
+ /**
+ * 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 static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
+ byte[] tradePrivateKey = generateTradePrivateKey();
+ byte[] secretB = generateSecret();
+ byte[] hashOfSecretB = Crypto.hash160(secretB);
+
+ byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = 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(BTC.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 = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount,
+ tradeBotCreateRequest.bitcoinAmount, 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, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM,
+ creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretB, hashOfSecretB,
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress));
+ notifyStateChange(tradeBotData);
+
+ // 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 static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
+ byte[] tradePrivateKey = generateTradePrivateKey();
+ byte[] secretA = generateSecret();
+ byte[] hashOfSecretA = Crypto.hash160(secretA);
+
+ byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = 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: halfway of refundTimeout from now
+ int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L);
+
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A,
+ receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretA, hashOfSecretA,
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash);
+
+ // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin
+ String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash);
+
+ long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-A */ + FEE_AMOUNT /* P2SH-B */;
+
+ Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired);
+ if (fundingCheckTransaction == null)
+ return ResponseResult.INSUFFICIENT_FUNDS;
+
+ // P2SH-A to be funded
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // Fund P2SH-A
+ Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT);
+ if (p2shFundingTransaction == null) {
+ LOGGER.warn(() -> String.format("Unable to build P2SH-A funding transaction - lack of funds?"));
+ return ResponseResult.BTC_BALANCE_ISSUE;
+ }
+
+ if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) {
+ // We couldn't fund P2SH-A at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?"));
+ return ResponseResult.BTC_NETWORK_ISSUE;
+ }
+
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress));
+ notifyStateChange(tradeBotData);
+
+ return ResponseResult.OK;
+ }
+
+ private 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();
+ }
+
+ private static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
+ return PrivateKeyAccount.toPublicKey(privateKey);
+ }
+
+ private static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
+ return ECKey.fromPrivate(privateKey).getPubKey();
+ }
+
+ private static byte[] generateSecret() {
+ byte[] secret = new byte[32];
+ RANDOM.nextBytes(secret);
+ return secret;
+ }
+
+ public void onChainTipChange() {
+ // No point doing anything on old/stale data
+ if (!Controller.getInstance().isUpToDate())
+ return;
+
+ if (!activeFlag.compareAndSet(false, true))
+ // Trade bot already active on another thread
+ return;
+
+ // Get repo for trade situations
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
+
+ for (TradeBotData tradeBotData : allTradeBotData) {
+ repository.discardChanges();
+
+ switch (tradeBotData.getState()) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ handleBobWaitingForAtConfirm(repository, tradeBotData);
+ break;
+
+ case ALICE_WAITING_FOR_P2SH_A:
+ handleAliceWaitingForP2shA(repository, tradeBotData);
+ break;
+
+ case BOB_WAITING_FOR_MESSAGE:
+ handleBobWaitingForMessage(repository, tradeBotData);
+ break;
+
+ case ALICE_WAITING_FOR_AT_LOCK:
+ handleAliceWaitingForAtLock(repository, tradeBotData);
+ break;
+
+ case BOB_WAITING_FOR_P2SH_B:
+ handleBobWaitingForP2shB(repository, tradeBotData);
+ break;
+
+ case ALICE_WATCH_P2SH_B:
+ handleAliceWatchingP2shB(repository, tradeBotData);
+ break;
+
+ case BOB_WAITING_FOR_AT_REDEEM:
+ handleBobWaitingForAtRedeem(repository, tradeBotData);
+ break;
+
+ case ALICE_DONE:
+ case BOB_DONE:
+ break;
+
+ case ALICE_REFUNDING_B:
+ handleAliceRefundingP2shB(repository, tradeBotData);
+ break;
+
+ case ALICE_REFUNDING_A:
+ handleAliceRefundingP2shA(repository, tradeBotData);
+ break;
+
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ break;
+
+ default:
+ LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name()));
+ }
+ }
+ } catch (DataException e) {
+ LOGGER.error("Couldn't run trade bot due to repository issue", e);
+ } finally {
+ activeFlag.set(false);
+ }
+ }
+
+ /**
+ * 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()))
+ return;
+
+ tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // If AT has finished then maybe Bob cancelled his trade offer
+ if (atData.getIsFinished()) {
+ // No point sending MESSAGE - might as well wait for refund
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress));
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ Long balance = BTC.getInstance().getBalance(p2shAddress);
+ if (balance == null || balance < crossChainTradeData.expectedBitcoin) {
+ if (balance != null && balance > 0)
+ LOGGER.debug(() -> String.format("P2SH-A balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin)));
+
+ return;
+ }
+
+ // P2SH-A funding confirmed
+
+ // Attempt to send MESSAGE to Bob's Qortal trade address
+ byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
+
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, 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", messageTransaction.getRecipient(), result.name()));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us",
+ p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException {
+ // Fetch AT so we can determine trade start timestamp
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ // If AT has finished then Bob likely cancelled his trade offer
+ if (atData.getIsFinished()) {
+ tradeBotData.setState(TradeBotData.State.BOB_REFUNDED);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ String address = tradeBotData.getTradeNativeAddress();
+ List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(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();
+ BTCACCT.OfferMessageData offerMessageData = BTCACCT.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[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript);
+
+ Long balance = BTC.getInstance().getBalance(p2shAddress);
+ if (balance == null || balance < tradeBotData.getBitcoinAmount())
+ continue;
+
+ // Good to go - send MESSAGE to AT
+
+ String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
+ int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
+
+ // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume
+ byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
+
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), 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", outgoingMessageTransaction.getRecipient(), result.name()));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret());
+ String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress));
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ // Don't resave if we don't need to
+ if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) {
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+ notifyStateChange(tradeBotData);
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A
+ if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) {
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ if (atData.getIsFinished())
+ LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress));
+ else
+ LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress));
+
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ // We're waiting for AT to be in TRADE mode
+ if (crossChainTradeData.mode != BTCACCT.Mode.TRADING)
+ return;
+
+ // We're expecting AT to be locked to our native trade address
+ if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) {
+ // AT locked to different address! We shouldn't continue but wait and refund.
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade",
+ tradeBotData.getAtAddress(),
+ crossChainTradeData.qortalPartnerAddress,
+ tradeBotData.getTradeNativeAddress(),
+ p2shAddress));
+
+ // There's no P2SH-B at this point, so jump straight to refunding P2SH-A
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ // Alice needs to fund P2SH-B here
+
+ // Find our MESSAGE to AT from previous state
+ List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null);
+ if (messageTransactionsData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT %s from repository", crossChainTradeData.qortalCreatorTradeAddress));
+ return;
+ }
+
+ // Find our message
+ Long recipientMessageTimestamp = null;
+ for (MessageTransactionData messageTransactionData : messageTransactionsData)
+ if (Arrays.equals(messageTransactionData.getSenderPublicKey(), tradeBotData.getTradeNativePublicKey())) {
+ recipientMessageTimestamp = messageTransactionData.getTimestamp();
+ break;
+ }
+
+ if (recipientMessageTimestamp == null) {
+ LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress));
+ return;
+ }
+
+ int lockTimeA = tradeBotData.getLockTimeA();
+ int lockTimeB = BTCACCT.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[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT);
+ if (p2shFundingTransaction == null) {
+ LOGGER.warn(() -> String.format("Unable to build P2SH-B funding transaction - lack of funds?"));
+ return;
+ }
+
+ if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) {
+ // We couldn't fund P2SH-B at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?"));
+ return;
+ }
+
+ // P2SH-B funded, now we wait for Bob to redeem it
+ tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B",
+ tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress));
+
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ // If we've passed AT refund timestamp then AT will have finished after auto-refunding
+ if (atData.getIsFinished()) {
+ tradeBotData.setState(TradeBotData.State.BOB_REFUNDED);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+
+ 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;
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ Long balance = BTC.getInstance().getBalance(p2shAddress);
+ if (balance == null || balance < FEE_AMOUNT) {
+ if (balance != null && balance > 0)
+ LOGGER.debug(() -> String.format("P2SH-B balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT)));
+
+ return;
+ }
+
+ // Redeem P2SH-B using secret-B
+ Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A
+ ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress);
+ byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
+
+ Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivingAccountInfo);
+
+ if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) {
+ // We couldn't redeem P2SH-B at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B redeeming transaction?"));
+ return;
+ }
+
+ // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT
+ tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // Refund P2SH-B if we've passed lockTime-B
+ if (NTP.getTime() >= crossChainTradeData.lockTimeB * 1000L) {
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress));
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress);
+ if (p2shTransactions == null) {
+ LOGGER.debug(() -> String.format("Unable to fetch transactions relating to %s", p2shAddress));
+ return;
+ }
+
+ byte[] secretB = BTCP2SH.findP2shSecret(p2shAddress, p2shTransactions);
+ 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 = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress);
+
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), 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", messageTransaction.getRecipient(), result.name()));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.ALICE_DONE);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ String receivingAddress = tradeBotData.getTradeNativeAddress();
+
+ LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s",
+ p2shAddress, tradeBotData.getAtAddress(), receivingAddress));
+
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ // AT should be 'finished' once Alice has redeemed QORT funds
+ if (!atData.getIsFinished())
+ // Not finished yet
+ return;
+
+ // If AT's balance should be zero
+ AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT);
+ if (atBalanceData != null && atBalanceData.getBalance() > 0L) {
+ LOGGER.debug(() -> String.format("AT %s should have zero balance, not %s", tradeBotData.getAtAddress(), Amounts.prettyAmount(atBalanceData.getBalance())));
+ return;
+ }
+
+ // We check variable in AT that is set when trade successfully completes
+ if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) {
+ tradeBotData.setState(TradeBotData.State.BOB_REFUNDED);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
+ notifyStateChange(tradeBotData);
+
+ return;
+ }
+
+ byte[] secretA = BTCACCT.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
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin);
+ ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress);
+ byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
+
+ Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivingAccountInfo);
+
+ if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) {
+ // We couldn't redeem P2SH-A at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A redeeming transaction?"));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.BOB_DONE);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo);
+
+ LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
+ notifyStateChange(tradeBotData);
+ }
+
+ /**
+ * 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.
+ */
+ private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ // We can't refund P2SH-B until lockTime-B has passed
+ if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L)
+ return;
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ Coin refundAmount = Coin.ZERO;
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress);
+
+ Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB);
+ if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) {
+ // We couldn't refund P2SH-B at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?"));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress));
+ notifyStateChange(tradeBotData);
+ }
+
+ /** Trade-bot is attempting to refund P2SH-A. */
+ private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException {
+ ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
+
+ // We can't refund P2SH-A until lockTime-A has passed
+ if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L)
+ return;
+
+ // We can't refund P2SH-A until we've passed median block time
+ Integer medianBlockTime = BTC.getInstance().getMedianBlockTime();
+ if (medianBlockTime == null || NTP.getTime() <= medianBlockTime * 1000L)
+ return;
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin);
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress);
+ if (fundingOutputs == null) {
+ LOGGER.debug(() -> String.format("Couldn't fetch unspent outputs for %s", p2shAddress));
+ return;
+ }
+
+ Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA());
+ if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) {
+ // We couldn't refund P2SH-A at this time
+ LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A refund transaction?"));
+ return;
+ }
+
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress));
+ notifyStateChange(tradeBotData);
+ }
+
+ private static void notifyStateChange(TradeBotData tradeBotData) {
+ StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
+ EventBus.INSTANCE.notify(stateChangeEvent);
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java
index ec53eb08..5e5a3639 100644
--- a/src/main/java/org/qortal/crosschain/BTC.java
+++ b/src/main/java/org/qortal/crosschain/BTC.java
@@ -1,28 +1,45 @@
package org.qortal.crosschain;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
+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.Coin;
+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.DeterministicHierarchy;
+import org.bitcoinj.crypto.DeterministicKey;
import org.bitcoinj.params.MainNetParams;
import org.bitcoinj.params.RegTestParams;
import org.bitcoinj.params.TestNet3Params;
+import org.bitcoinj.script.Script.ScriptType;
import org.bitcoinj.script.ScriptBuilder;
import org.bitcoinj.utils.MonetaryFormat;
+import org.bitcoinj.wallet.DeterministicKeyChain;
+import org.bitcoinj.wallet.SendRequest;
+import org.bitcoinj.wallet.Wallet;
+import org.qortal.crosschain.ElectrumX.UnspentOutput;
+import org.qortal.crypto.Crypto;
import org.qortal.settings.Settings;
import org.qortal.utils.BitTwiddling;
-import org.qortal.utils.Pair;
+
+import com.google.common.hash.HashCode;
public class BTC {
- public static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
public static final int HASH160_LENGTH = 20;
@@ -30,6 +47,7 @@ public class BTC {
protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
+ private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
public enum BitcoinNet {
MAIN {
@@ -58,6 +76,9 @@ public class BTC {
private final NetworkParameters params;
private final ElectrumX electrumX;
+ // Let ECKey.equals() do the hard work
+ private final Set spentKeys = new HashSet<>();
+
// Constructors and instance
private BTC() {
@@ -88,6 +109,34 @@ public class BTC {
// Actual useful methods for use by other classes
+ public static String format(Coin amount) {
+ return BTC.FORMAT.format(amount).toString();
+ }
+
+ public static String format(long amount) {
+ return format(Coin.valueOf(amount));
+ }
+
+ public boolean isValidXprv(String xprv58) {
+ try {
+ DeterministicKey.deserializeB58(null, xprv58, this.params);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /** Returns P2PKH Bitcoin address using passed public key hash. */
+ public String pkhToAddress(byte[] publicKeyHash) {
+ return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
+ }
+
+ public String deriveP2shAddress(byte[] redeemScriptBytes) {
+ byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
+ Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
+ return p2shAddress.toString();
+ }
+
/** Returns median timestamp from latest 11 blocks, in seconds. */
public Integer getMedianBlockTime() {
Integer height = this.electrumX.getCurrentHeight();
@@ -99,34 +148,31 @@ public class BTC {
if (blockHeaders == null || blockHeaders.size() < 11)
return null;
- List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
+ List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
- // Descending, but order shouldn't matter as we're picking median...
+ // Descending order
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
+ // Pick median
return blockTimestamps.get(5);
}
- public Coin getBalance(String base58Address) {
- Long balance = this.electrumX.getBalance(addressToScript(base58Address));
- if (balance == null)
- return null;
-
- return Coin.valueOf(balance);
+ public Long getBalance(String base58Address) {
+ return this.electrumX.getBalance(addressToScript(base58Address));
}
public List getUnspentOutputs(String base58Address) {
- List> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
+ List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
if (unspentOutputs == null)
return null;
List unspentTransactionOutputs = new ArrayList<>();
- for (Pair unspentOutput : unspentOutputs) {
- List transactionOutputs = getOutputs(unspentOutput.getA());
+ for (UnspentOutput unspentOutput : unspentOutputs) {
+ List transactionOutputs = getOutputs(unspentOutput.hash);
if (transactionOutputs == null)
return null;
- unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB()));
+ unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
}
return unspentTransactionOutputs;
@@ -141,6 +187,7 @@ public class BTC {
return transaction.getOutputs();
}
+ /** Returns list of raw transactions spending passed address. */
public List getAddressTransactions(String base58Address) {
return this.electrumX.getAddressTransactions(addressToScript(base58Address));
}
@@ -149,6 +196,181 @@ public class BTC {
return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize());
}
+ /**
+ * Returns bitcoinj transaction sending amount to recipient.
+ *
+ * @param xprv58 BIP32 extended Bitcoin 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) {
+ Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
+ wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT));
+
+ Address destination = Address.fromString(this.params, recipient);
+ SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
+
+ if (this.params == TestNet3Params.get())
+ // Much smaller fee for TestNet3
+ sendRequest.feePerKb = Coin.valueOf(2000L);
+
+ try {
+ wallet.completeTx(sendRequest);
+ return sendRequest.tx;
+ } catch (InsufficientMoneyException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns unspent Bitcoin balance given 'm' BIP32 key.
+ *
+ * @param xprv58 BIP32 extended Bitcoin private key
+ * @return unspent BTC balance, or null if unable to determine balance
+ */
+ public Long getWalletBalance(String xprv58) {
+ Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
+ wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
+
+ Coin balance = wallet.getBalance();
+ if (balance == null)
+ return null;
+
+ return balance.value;
+ }
+
+ // UTXOProvider support
+
+ static class WalletAwareUTXOProvider implements UTXOProvider {
+ private static final int LOOKAHEAD_INCREMENT = 3;
+
+ private final BTC btc;
+ private final Wallet wallet;
+
+ enum KeySearchMode {
+ REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT;
+ }
+ private final KeySearchMode keySearchMode;
+ private final DeterministicKeyChain keyChain;
+
+ public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) {
+ this.btc = btc;
+ this.wallet = wallet;
+ this.keySearchMode = keySearchMode;
+ this.keyChain = this.wallet.getActiveKeyChain();
+
+ // Set up wallet's key chain
+ this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT);
+ this.keyChain.maybeLookAhead();
+ }
+
+ public List getOpenTransactionOutputs(List keys) throws UTXOProviderException {
+ List allUnspentOutputs = new ArrayList<>();
+ final boolean coinbase = false;
+
+ int ki = 0;
+ do {
+ boolean areAllKeysUnspent = true;
+ boolean areAllKeysSpent = true;
+
+ for (; ki < keys.size(); ++ki) {
+ ECKey key = keys.get(ki);
+
+ Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH);
+ byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
+
+ List unspentOutputs = btc.electrumX.getUnspentOutputs(script);
+ if (unspentOutputs == null)
+ 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 (btc.spentKeys.contains(key)) {
+ wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
+ areAllKeysUnspent = false;
+ continue;
+ }
+
+ // Ask for transaction history - if it's empty then key has never been used
+ List historicTransactionHashes = btc.electrumX.getAddressTransactions(script);
+ if (historicTransactionHashes == null)
+ throw new UTXOProviderException(
+ String.format("Unable to fetch transaction history for %s", address));
+
+ if (!historicTransactionHashes.isEmpty()) {
+ // Fully spent key - case (a)
+ btc.spentKeys.add(key);
+ wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
+ areAllKeysUnspent = false;
+ } else {
+ // Key never been used - case (b)
+ areAllKeysSpent = false;
+ }
+
+ continue;
+ }
+
+ // If we reach here, then there's definitely at least one unspent key
+ areAllKeysSpent = false;
+
+ for (UnspentOutput unspentOutput : unspentOutputs) {
+ List transactionOutputs = btc.getOutputs(unspentOutput.hash);
+ if (transactionOutputs == null)
+ 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 ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent)
+ || (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) {
+ // Generate some more keys
+ this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT);
+ this.keyChain.maybeLookAhead();
+
+ // This returns all keys, including those already in 'keys'
+ List allLeafKeys = this.keyChain.getLeafKeys();
+ // Add only new keys onto our list of keys to search
+ List newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
+ keys.addAll(newKeys);
+ // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
+ }
+
+ // If we have processed all keys, then we're done
+ } while (ki < keys.size());
+
+ return allUnspentOutputs;
+ }
+
+ public int getChainHeadHeight() throws UTXOProviderException {
+ Integer height = btc.electrumX.getCurrentHeight();
+ if (height == null)
+ throw new UTXOProviderException("Unable to determine Bitcoin chain height");
+
+ return height.intValue();
+ }
+
+ public NetworkParameters getParams() {
+ return btc.params;
+ }
+ }
+
// Utility methods for us
private byte[] addressToScript(String base58Address) {
diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java
index a0246d04..1adacfb8 100644
--- a/src/main/java/org/qortal/crosschain/BTCACCT.java
+++ b/src/main/java/org/qortal/crosschain/BTCACCT.java
@@ -1,26 +1,14 @@
package org.qortal.crosschain;
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
import static org.ciyam.at.OpCode.calcOffset;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.List;
-import java.util.function.Function;
+import java.util.Map;
-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.ciyam.at.API;
import org.ciyam.at.CompilationException;
import org.ciyam.at.FunctionCode;
@@ -29,14 +17,12 @@ import org.ciyam.at.OpCode;
import org.ciyam.at.Timestamp;
import org.qortal.account.Account;
import org.qortal.asset.Asset;
-import org.qortal.at.QortalAtLoggerFactory;
-import org.qortal.block.BlockChain;
-import org.qortal.block.BlockChain.CiyamAtSettings;
+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.block.BlockData;
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;
@@ -45,246 +31,197 @@ import org.qortal.utils.BitTwiddling;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
-/*
- * Bob generates Bitcoin private key
- * private key required to sign P2SH redeem tx
- * private key can be used to create 'secret' (e.g. double-SHA256)
- * encrypted private key could be stored in Qortal AT for access by Bob from any node
- * Bob creates Qortal AT
- * Alice finds Qortal AT and wants to trade
- * Alice generates Bitcoin private key
- * Alice will need to send Bob her Qortal address and Bitcoin refund address
- * Bob sends Alice's Qortal address to Qortal AT
- * Qortal AT sends initial QORT payment to Alice (so she has QORT to send message to AT and claim funds)
- * Alice receives funds and checks Qortal AT to confirm it's locked to her
- * Alice creates/funds Bitcoin P2SH
- * Alice requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
- * Bob checks P2SH is funded
- * Bob requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime
- * Bob uses secret to redeem P2SH
- * Qortal core/UI will need to create, and sign, this transaction
- * Alice scans P2SH redeem tx and uses secret to redeem Qortal AT
+/**
+ * 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 BTCACCT {
public static final int SECRET_LENGTH = 32;
public static final int MIN_LOCKTIME = 1500000000;
- public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes
+ public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
- /*
- * 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
- */
+ /** 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);
- 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 Bitcoin redeemScript used for cross-chain trading.
- *
- * See comments in {@link BTCACCT} 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 secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
- * @return
- */
- public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
- return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
- redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
+ 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*/;
- /**
- * Builds a custom transaction to spend P2SH.
- *
- * @param amount output amount, should be total of input amounts, less miner fees
- * @param spendKey key for signing transaction, and also where funds are 'sent' (output)
- * @param fundingOutput output from transaction that funded P2SH address
- * @param redeemScriptBytes the redeemScript itself, in byte[] form
- * @param lockTime (optional) transaction nLockTime, used in refund scenario
- * @param scriptSigBuilder function for building scriptSig using transaction input signature
- * @return Signed Bitcoin transaction for spending P2SH
- */
- public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) {
- NetworkParameters params = BTC.getInstance().getNetworkParameters();
+ public enum Mode {
+ OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
- Transaction transaction = new Transaction(params);
- transaction.setVersion(2);
+ public final int value;
+ private static final Map map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode));
- // Output is back to P2SH funder
- transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash()));
-
- 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(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
- else
- input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
- transaction.addInput(input);
+ Mode(int value) {
+ this.value = value;
}
- // 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);
+ public static Mode valueOf(int value) {
+ return map.get(value);
}
-
- return transaction;
}
- /**
- * Returns signed Bitcoin transaction claiming refund from P2SH address.
- *
- * @param refundAmount refund amount, should be total of input amounts, less miner fees
- * @param refundKey key for signing transaction, and also where refund is 'sent' (output)
- * @param fundingOutput output from transaction that funded P2SH address
- * @param redeemScriptBytes the redeemScript itself, in byte[] form
- * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
- * @return Signed Bitcoin transaction for refunding P2SH
- */
- public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime) {
- 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();
- };
-
- return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder);
- }
-
- /**
- * Returns signed Bitcoin transaction redeeming funds from P2SH address.
- *
- * @param redeemAmount redeem amount, should be total of input amounts, less miner fees
- * @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
- * @param fundingOutput output from transaction that funded P2SH address
- * @param redeemScriptBytes the redeemScript itself, in byte[] form
- * @param secret actual 32-byte secret used when building redeemScript
- * @return Signed Bitcoin transaction for redeeming P2SH
- */
- public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret) {
- 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(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder);
+ private BTCACCT() {
}
/**
* Returns Qortal AT creation bytes for cross-chain trading AT.
*
- * tradeTimeout (minutes) is the time window for the recipient to send the
+ * 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 qortalCreator Qortal address for AT creator, also used for refunds
- * @param secretHash 20-byte HASH160 of 32-byte secret
- * @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator
- * @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode'
- * @param redeemPayout how much QORT to pay trade partner if they send correct 32-byte secret to AT
+ * @param 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
* @return
*/
- public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) {
+ 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 addrQortalCreator1 = addrCounter++;
- final int addrQortalCreator2 = addrCounter++;
- final int addrQortalCreator3 = addrCounter++;
- final int addrQortalCreator4 = addrCounter++;
+ final int addrCreatorTradeAddress1 = addrCounter++;
+ final int addrCreatorTradeAddress2 = addrCounter++;
+ final int addrCreatorTradeAddress3 = addrCounter++;
+ final int addrCreatorTradeAddress4 = addrCounter++;
- final int addrSecretHash = addrCounter;
+ final int addrBitcoinPublicKeyHash = addrCounter;
addrCounter += 4;
- final int addrTradeTimeout = addrCounter++;
- final int addrInitialPayoutAmount = addrCounter++;
- final int addrRedeemPayoutAmount = addrCounter++;
+ final int addrHashOfSecretB = addrCounter;
+ addrCounter += 4;
+
+ final int addrQortAmount = addrCounter++;
final int addrBitcoinAmount = addrCounter++;
+ final int addrTradeTimeout = addrCounter++;
- final int addrMessageTxType = addrCounter++;
+ final int addrMessageTxnType = addrCounter++;
+ final int addrExpectedTradeMessageLength = addrCounter++;
+ final int addrExpectedRedeemMessageLength = addrCounter++;
- final int addrSecretHashPointer = addrCounter++;
- final int addrQortalRecipientPointer = 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 addrQortalRecipient1 = addrCounter++;
- final int addrQortalRecipient2 = addrCounter++;
- final int addrQortalRecipient3 = addrCounter++;
- final int addrQortalRecipient4 = addrCounter++;
+ final int addrCreatorAddress1 = addrCounter++;
+ final int addrCreatorAddress2 = addrCounter++;
+ final int addrCreatorAddress3 = addrCounter++;
+ final int addrCreatorAddress4 = addrCounter++;
- final int addrTradeRefundTimestamp = addrCounter++;
- final int addrLastTxTimestamp = 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 addrTxType = addrCounter++;
+ final int addrTxnType = addrCounter++;
final int addrResult = addrCounter++;
final int addrMessageSender1 = addrCounter++;
@@ -292,72 +229,131 @@ public class BTCACCT {
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 Qortal address, decoded from Base58
- assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect";
- byte[] qortalCreatorBytes = Base58.decode(qortalCreator);
- dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0));
+ // 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));
- // Hash of secret
- assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect";
- dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0));
+ // Bitcoin public key hash
+ assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect";
+ dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0));
- // Trade timeout in minutes
- assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect";
- dataByteBuffer.putLong(tradeTimeout);
+ // Hash of secret-B
+ assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect";
+ dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0));
- // Initial payout amount
- assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect";
- dataByteBuffer.putLong(initialPayout);
-
- // Redeem payout amount
- assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect";
- dataByteBuffer.putLong(redeemPayout);
+ // 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() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect";
+ assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect";
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
- // Index into data segment of hash, used by GET_B_IND
- assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect";
- dataByteBuffer.putLong(addrSecretHash);
+ // Expected length of 'trade' MESSAGE data from AT creator
+ assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect";
+ dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH);
- // Index into data segment of recipient address, used by SET_B_IND
- assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect";
- dataByteBuffer.putLong(addrQortalRecipient1);
+ // 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 labelOfferTxLoop = null;
- Integer labelCheckOfferTx = 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;
- Integer labelTradeMode = null;
- Integer labelTradeTxLoop = null;
- Integer labelCheckTradeTx = null;
-
- ByteBuffer codeByteBuffer = ByteBuffer.allocate(512);
+ ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
// Two-pass version
for (int pass = 0; pass < 2; ++pass) {
@@ -367,146 +363,223 @@ public class BTCACCT {
/* Initialization */
// Use AT creation 'timestamp' as starting point for finding transactions sent to AT
- codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp));
+ 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 owner containing trade partner details, or AT owner's address to cancel offer */
+ /* 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 */
- labelOfferTxLoop = codeByteBuffer.position();
+ labelTradeTxnLoop = codeByteBuffer.position();
- // Find next transaction to this AT since the last one (if any)
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp));
- // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0.
+ // 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, labelCheckOfferTx)));
+ codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn)));
// Stop and wait for next block
codeByteBuffer.put(OpCode.STP_IMD.compile());
/* Check transaction */
- labelCheckOfferTx = codeByteBuffer.position();
+ 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, addrLastTxTimestamp));
- // Extract transaction type (message/payment) from transaction and save type in addrTxType
- codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
+ 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(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop)));
+ codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop)));
- /* Check transaction's sender */
+ /* 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 transaction's sender's address with expected address. If they don't match, look for another transaction.
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop)));
+ // 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));
- /* Extract trade partner info from message */
+ /* 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, Mode.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 addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer)
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer));
- // Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner.
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrQortalCreator1, calcOffset(codeByteBuffer, labelTradeMode)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrQortalCreator2, calcOffset(codeByteBuffer, labelTradeMode)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrQortalCreator3, calcOffset(codeByteBuffer, labelTradeMode)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrQortalCreator4, calcOffset(codeByteBuffer, labelTradeMode)));
- // Recipient address is AT creator's address, so cancel offer and finish.
- codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund));
+ // 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));
- /* Switch to 'trade mode' */
- labelTradeMode = codeByteBuffer.position();
+ // 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));
- // Send initial payment to recipient so they have enough funds to message AT if all goes well
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrInitialPayoutAmount));
+ // Grab next 32 bytes
+ codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset));
- // Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp
- codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout));
+ // 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, Mode.TRADING.value));
// Set restart position to after this opcode
codeByteBuffer.put(OpCode.SET_PCS.compile());
- /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */
+ /* 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, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop)));
+ 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 */
- labelTradeTxLoop = codeByteBuffer.position();
+ 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, addrLastTxTimestamp));
+ 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, labelCheckTradeTx)));
+ codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn)));
// Stop and wait for next block
codeByteBuffer.put(OpCode.STP_IMD.compile());
/* Check transaction */
- labelCheckTradeTx = codeByteBuffer.position();
+ 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, addrLastTxTimestamp));
- // Extract transaction type (message/payment) from transaction and save type in addrTxType
- codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType));
+ 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(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop)));
+ 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, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop)));
- codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop)));
+ 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' in transaction's message */
+ /* Check 'secret-A' in transaction's message */
- // Extract message from transaction into B register
+ // 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 addrSecretHashPointer)
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer));
+ // 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.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop)));
+ codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB)));
+ codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
- /* Success! Pay arranged amount to intended recipient */
+ /* 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, Mode.REDEEMED.value));
+ // We're finished forever (finishing auto-refunds remaining balance to AT creator)
+ codeByteBuffer.put(OpCode.FIN_IMD.compile());
- // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer)
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer));
- // Pay AT's balance to recipient
- codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount));
// Fall-through to refunding any remaining balance back to AT creator
/* Refund balance back to AT creator */
labelRefund = codeByteBuffer.position();
- // Load B register with AT creator's address.
- codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B));
- // Pay AT's balance back to AT's creator.
- codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B));
- // We're finished forever
+ // Set refunded mode
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.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);
@@ -537,130 +610,306 @@ public class BTCACCT {
* @throws DataException
*/
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
- String atAddress = atData.getATAddress();
+ ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
+ return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData);
+ }
- ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress);
- byte[] stateData = atStateData.getStateData();
+ /**
+ * Returns CrossChainTradeData with useful info extracted from AT.
+ *
+ * @param repository
+ * @param atAddress
+ * @throws DataException
+ */
+ public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
+ byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress());
+ return populateTradeData(repository, creatorPublicKey, atStateData);
+ }
- QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
- byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData);
+ /**
+ * Returns CrossChainTradeData with useful info extracted from AT.
+ *
+ * @param repository
+ * @param atAddress
+ * @throws DataException
+ */
+ public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException {
+ byte[] addressBytes = new byte[25]; // for general use
+ String atAddress = atStateData.getATAddress();
CrossChainTradeData tradeData = new CrossChainTradeData();
+
tradeData.qortalAtAddress = atAddress;
- tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey());
- tradeData.creationTimestamp = atData.getCreation();
+ tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
+ tradeData.creationTimestamp = atStateData.getCreation();
Account atAccount = new Account(repository, atAddress);
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
- ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes);
- byte[] addressBytes = new byte[32];
+ byte[] stateData = atStateData.getStateData();
+ ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData);
+ dataByteBuffer.position(MachineState.HEADER_LENGTH);
- // Skip AT creator address
- dataByteBuffer.position(dataByteBuffer.position() + 32);
+ /* Constants */
- // Hash of secret
- tradeData.secretHash = new byte[20];
- dataByteBuffer.get(tradeData.secretHash);
- dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes
+ // Skip creator's trade address
+ dataByteBuffer.get(addressBytes);
+ tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
- // Trade timeout
- tradeData.tradeRefundTimeout = dataByteBuffer.getLong();
+ // Creator's Bitcoin/foreign public key hash
+ tradeData.creatorBitcoinPKH = new byte[20];
+ dataByteBuffer.get(tradeData.creatorBitcoinPKH);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes
- // Initial payout
- tradeData.initialPayout = dataByteBuffer.getLong();
+ // 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.redeemPayout = dataByteBuffer.getLong();
+ tradeData.qortAmount = dataByteBuffer.getLong();
// Expected BTC amount
tradeData.expectedBitcoin = dataByteBuffer.getLong();
+ // Trade timeout
+ tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
+
// Skip MESSAGE transaction type
dataByteBuffer.position(dataByteBuffer.position() + 8);
- // Skip pointer to secretHash
+ // Skip expected 'trade' message length
dataByteBuffer.position(dataByteBuffer.position() + 8);
- // Skip pointer to Qortal recipient
+ // 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);
- // Qortal recipient (if any)
- dataByteBuffer.get(addressBytes);
+ // Skip pointer to partner's receiving address
+ dataByteBuffer.position(dataByteBuffer.position() + 8);
- // Trade offer timeout (AT 'timestamp' converted to Qortal block height)
+ /* 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();
- if (tradeRefundTimestamp != 0) {
- tradeData.mode = CrossChainTradeData.Mode.TRADE;
+ // 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();
+ Mode mode = Mode.valueOf((int) (modeValue & 0xffL));
+
+ /* End of variables */
+
+ if (mode != null && mode != Mode.OFFERING) {
+ tradeData.mode = mode;
+ tradeData.refundTimeout = refundTimeout;
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
+ tradeData.qortalPartnerAddress = qortalRecipient;
+ tradeData.hashOfSecretA = hashOfSecretA;
+ tradeData.partnerBitcoinPKH = partnerBitcoinPKH;
+ tradeData.lockTimeA = lockTimeA;
+ tradeData.lockTimeB = lockTimeB;
- if (addressBytes[0] != 0)
- tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH));
-
- // We'll suggest half of trade timeout
- CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings();
-
- int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeRefundTimeout / ciyamAtSettings.minutesPerBlock);
-
- BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight);
- if (blockData != null) {
- tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch
- tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeRefundTimeout / 2 * 60);
- }
+ if (mode == Mode.REDEEMED)
+ tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
} else {
- tradeData.mode = CrossChainTradeData.Mode.OFFER;
+ tradeData.mode = Mode.OFFERING;
}
return tradeData;
}
- public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) {
- NetworkParameters params = BTC.getInstance().getNetworkParameters();
+ /** 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);
+ }
- for (byte[] rawTransaction : rawTransactions) {
- Transaction transaction = new Transaction(params, rawTransaction);
+ /** 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;
- // Cycle through inputs, looking for one that spends our P2SH
- for (TransactionInput input : transaction.getInputs()) {
- Script scriptSig = input.getScriptSig();
- List scriptChunks = scriptSig.getChunks();
+ OfferMessageData offerMessageData = new OfferMessageData();
+ offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20);
+ offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40);
+ offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40);
- // 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;
+ return offerMessageData;
+ }
- // We're expecting last chunk to contain the actual redeemScript
- ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1);
- byte[] redeemScriptBytes = lastChunk.data;
+ /** 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);
- // If non-push scripts, redeemScript will be null
- if (redeemScriptBytes == null)
- continue;
+ 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);
- byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
- Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
+ return data;
+ }
- if (!inputAddress.toString().equals(p2shAddress))
- // Input isn't spending our P2SH
- continue;
+ /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
+ public static byte[] buildCancelMessage(String creatorQortalAddress) {
+ byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
+ byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
- byte[] secret = scriptChunks.get(0).data;
- if (secret.length != BTCACCT.SECRET_LENGTH)
- continue;
+ System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length);
- return secret;
- }
+ 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 offerMessageTimesamp 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;
+
+ List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(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/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java
new file mode 100644
index 00000000..8a0fa546
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java
@@ -0,0 +1,236 @@
+package org.qortal.crosschain;
+
+import java.util.List;
+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.BitTwiddling;
+
+import com.google.common.hash.HashCode;
+import com.google.common.primitives.Bytes;
+
+public class BTCP2SH {
+
+ public static final int SECRET_LENGTH = 32;
+ public static final int MIN_LOCKTIME = 1500000000;
+
+ /*
+ * 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 Bitcoin redeemScript used for cross-chain trading.
+ *
+ * See comments in {@link BTCP2SH} 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 secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
+ * @return
+ */
+ public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
+ return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
+ redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
+ }
+
+ /**
+ * Builds a custom transaction to spend P2SH.
+ *
+ * @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 Bitcoin transaction for spending P2SH
+ */
+ public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes,
+ Long lockTime, Function scriptSigBuilder, byte[] outputPublicKeyHash) {
+ NetworkParameters params = BTC.getInstance().getNetworkParameters();
+
+ 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(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
+ else
+ input.setSequenceNumber(BTC.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 Bitcoin transaction claiming refund from P2SH address.
+ *
+ * @param refundAmount refund amount, should be total of input amounts, less miner fees
+ * @param refundKey key for signing transaction, and also where refund is 'sent' (output)
+ * @param fundingOutput output from transaction that funded P2SH address
+ * @param redeemScriptBytes the redeemScript itself, in byte[] form
+ * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
+ * @return Signed Bitcoin transaction for refunding P2SH
+ */
+ public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime) {
+ 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(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash());
+ }
+
+ /**
+ * Returns signed Bitcoin transaction redeeming funds from P2SH address.
+ *
+ * @param redeemAmount redeem amount, should be total of input amounts, less miner fees
+ * @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
+ * @param fundingOutput output from transaction that funded P2SH address
+ * @param redeemScriptBytes the redeemScript itself, in byte[] form
+ * @param secret actual 32-byte secret used when building redeemScript
+ * @param receivingAccountInfo Bitcoin PKH used for output
+ * @return Signed Bitcoin transaction for redeeming P2SH
+ */
+ public static Transaction buildRedeemTransaction(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(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
+ }
+
+ /** Returns 'secret', if any, given list of raw bitcoin transactions. */
+ public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) {
+ NetworkParameters params = BTC.getInstance().getNetworkParameters();
+
+ for (byte[] rawTransaction : rawTransactions) {
+ Transaction transaction = new Transaction(params, rawTransaction);
+
+ // Cycle through inputs, looking for one that spends our P2SH
+ 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 P2SH
+ continue;
+
+ byte[] secret = scriptChunks.get(0).data;
+ if (secret.length != BTCP2SH.SECRET_LENGTH)
+ continue;
+
+ return secret;
+ }
+ }
+
+ return null;
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java
index 41c3d99d..8b10e52a 100644
--- a/src/main/java/org/qortal/crosschain/ElectrumX.java
+++ b/src/main/java/org/qortal/crosschain/ElectrumX.java
@@ -25,11 +25,11 @@ import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.qortal.crypto.Crypto;
import org.qortal.crypto.TrustlessSSLSocketFactory;
-import org.qortal.utils.Pair;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
+/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */
public class ElectrumX {
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
@@ -93,7 +93,21 @@ public class ElectrumX {
private ElectrumX(String bitcoinNetwork) {
switch (bitcoinNetwork) {
case "MAIN":
- servers.addAll(Arrays.asList());
+ servers.addAll(Arrays.asList(
+ // Servers chosen on NO BASIS WHATSOEVER from various sources!
+ new Server("tardis.bauerj.eu", Server.ConnectionType.SSL, 50002),
+ new Server("rbx.curalle.ovh", Server.ConnectionType.SSL, 50002),
+ new Server("quick.electumx.live", Server.ConnectionType.SSL, 50002),
+ new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
+ new Server("electrumx.ddns.net", Server.ConnectionType.SSL, 50002),
+ new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
+ new Server("electrum.eff.ro", Server.ConnectionType.SSL, 50002),
+ new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
+ new Server("E-X.not.fyi", Server.ConnectionType.SSL, 50002),
+ new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
+ new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 50001),
+ new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002),
+ new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001)));
break;
case "TEST3":
@@ -119,6 +133,7 @@ public class ElectrumX {
rpc("server.banner");
}
+ /** Returns ElectrumX instance linked to passed Bitcoin network, one of "MAIN", "TEST3" or "REGTEST". */
public static synchronized ElectrumX getInstance(String bitcoinNetwork) {
if (!instances.containsKey(bitcoinNetwork))
instances.put(bitcoinNetwork, new ElectrumX(bitcoinNetwork));
@@ -129,16 +144,26 @@ public class ElectrumX {
// Methods for use by other classes
public Integer getCurrentHeight() {
- JSONObject blockJson = (JSONObject) this.rpc("blockchain.headers.subscribe");
- if (blockJson == null || !blockJson.containsKey("height"))
+ Object blockObj = this.rpc("blockchain.headers.subscribe");
+ if (!(blockObj instanceof JSONObject))
+ return null;
+
+ JSONObject blockJson = (JSONObject) blockObj;
+
+ if (!blockJson.containsKey("height"))
return null;
return ((Long) blockJson.get("height")).intValue();
}
public List getBlockHeaders(int startHeight, long count) {
- JSONObject blockJson = (JSONObject) this.rpc("blockchain.block.headers", startHeight, count);
- if (blockJson == null || !blockJson.containsKey("count") || !blockJson.containsKey("hex"))
+ Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
+ if (!(blockObj instanceof JSONObject))
+ return null;
+
+ JSONObject blockJson = (JSONObject) blockObj;
+
+ if (!blockJson.containsKey("count") || !blockJson.containsKey("hex"))
return null;
Long returnedCount = (Long) blockJson.get("count");
@@ -155,57 +180,87 @@ public class ElectrumX {
return rawBlockHeaders;
}
+ /** Returns confirmed balance, based on passed payment script, or null if there was an error or no known balance. */
public Long getBalance(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
- JSONObject balanceJson = (JSONObject) this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
- if (balanceJson == null || !balanceJson.containsKey("confirmed"))
+ Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
+ if (!(balanceObj instanceof JSONObject))
+ return null;
+
+ JSONObject balanceJson = (JSONObject) balanceObj;
+
+ if (!balanceJson.containsKey("confirmed"))
return null;
return (Long) balanceJson.get("confirmed");
}
- public List> getUnspentOutputs(byte[] script) {
+ /** Unspent output info as returned by ElectrumX network. */
+ public static class UnspentOutput {
+ public final byte[] hash;
+ public final int index;
+ public final int height;
+ public final long value;
+
+ public UnspentOutput(byte[] hash, int index, int height, long value) {
+ this.hash = hash;
+ this.index = index;
+ this.height = height;
+ this.value = value;
+ }
+ }
+
+ /** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */
+ public List getUnspentOutputs(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
- JSONArray unspentJson = (JSONArray) this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
- if (unspentJson == null)
+ Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
+ if (!(unspentJson instanceof JSONArray))
return null;
- List> unspentOutputs = new ArrayList<>();
- for (Object rawUnspent : unspentJson) {
+ List unspentOutputs = new ArrayList<>();
+ for (Object rawUnspent : (JSONArray) unspentJson) {
JSONObject unspent = (JSONObject) rawUnspent;
+ int height = ((Long) unspent.get("height")).intValue();
+ // We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0)
+ if (height <= 0)
+ continue;
+
byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes();
int outputIndex = ((Long) unspent.get("tx_pos")).intValue();
+ long value = (Long) unspent.get("value");
- unspentOutputs.add(new Pair<>(txHash, outputIndex));
+ unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value));
}
return unspentOutputs;
}
+ /** Returns raw transaction for passed transaction hash, or null if not found. */
public byte[] getRawTransaction(byte[] txHash) {
- String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
- if (rawTransactionHex == null)
+ Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
+ if (!(rawTransactionHex instanceof String))
return null;
- return HashCode.fromString(rawTransactionHex).asBytes();
+ return HashCode.fromString((String) rawTransactionHex).asBytes();
}
+ /** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */
public List getAddressTransactions(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
- JSONArray transactionsJson = (JSONArray) this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
- if (transactionsJson == null)
+ Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
+ if (!(transactionsJson instanceof JSONArray))
return null;
List rawTransactions = new ArrayList<>();
- for (Object rawTransactionInfo : transactionsJson) {
+ for (Object rawTransactionInfo : (JSONArray) transactionsJson) {
JSONObject transactionInfo = (JSONObject) rawTransactionInfo;
// We only want confirmed transactions
@@ -223,6 +278,7 @@ public class ElectrumX {
return rawTransactions;
}
+ /** Returns true if raw transaction successfully broadcast. */
public boolean broadcastTransaction(byte[] transactionBytes) {
Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString());
if (rawBroadcastResult == null)
@@ -235,14 +291,15 @@ public class ElectrumX {
// Class-private utility methods
+ /** Query current server for its list of peer servers, and return those we can parse. */
private Set serverPeersSubscribe() {
Set newServers = new HashSet<>();
- JSONArray peers = (JSONArray) this.connectedRpc("server.peers.subscribe");
- if (peers == null)
+ Object peers = this.connectedRpc("server.peers.subscribe");
+ if (!(peers instanceof JSONArray))
return newServers;
- for (Object rawPeer : peers) {
+ for (Object rawPeer : (JSONArray) peers) {
JSONArray peer = (JSONArray) rawPeer;
if (peer.size() < 3)
continue;
@@ -287,6 +344,7 @@ public class ElectrumX {
return newServers;
}
+ /** Return output from RPC call, with automatic reconnection to different server if needed. */
private synchronized Object rpc(String method, Object...params) {
while (haveConnection()) {
Object response = connectedRpc(method, params);
@@ -305,6 +363,7 @@ public class ElectrumX {
return null;
}
+ /** Returns true if we have, or create, a connection to an ElectrumX server. */
private boolean haveConnection() {
if (this.currentServer != null)
return true;
@@ -377,10 +436,12 @@ public class ElectrumX {
if (response.isEmpty())
return null;
- JSONObject responseJson = (JSONObject) JSONValue.parse(response);
- if (responseJson == null)
+ Object responseObj = JSONValue.parse(response);
+ if (!(responseObj instanceof JSONObject))
return null;
+ JSONObject responseJson = (JSONObject) responseObj;
+
return responseJson.get("result");
}
diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java
index 8c9b6602..f445f58e 100644
--- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java
+++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java
@@ -4,14 +4,14 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import org.qortal.crosschain.BTCACCT;
+
import io.swagger.v3.oas.annotations.media.Schema;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeData {
- public enum Mode { OFFER, TRADE };
-
// Properties
@Schema(description = "AT's Qortal address")
@@ -20,32 +20,40 @@ public class CrossChainTradeData {
@Schema(description = "AT creator's Qortal address")
public String qortalCreator;
+ @Schema(description = "AT creator's Qortal trade address")
+ public String qortalCreatorTradeAddress;
+
+ @Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)")
+ public byte[] creatorBitcoinPKH;
+
@Schema(description = "Timestamp when AT was created (milliseconds since epoch)")
public long creationTimestamp;
+ @Schema(description = "Suggested trade timeout (minutes)", example = "10080")
+ public int tradeTimeout;
+
@Schema(description = "AT's current QORT balance")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long qortBalance;
- @Schema(description = "HASH160 of 32-byte secret")
- public byte[] secretHash;
+ @Schema(description = "HASH160 of 32-byte secret-A")
+ public byte[] hashOfSecretA;
- @Schema(description = "Initial QORT payment that will be sent to Qortal trade partner")
- @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
- public long initialPayout;
+ @Schema(description = "HASH160 of 32-byte secret-B")
+ public byte[] hashOfSecretB;
@Schema(description = "Final QORT payment that will be sent to Qortal trade partner")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
- public long redeemPayout;
+ public long qortAmount;
@Schema(description = "Trade partner's Qortal address (trade begins when this is set)")
- public String qortalRecipient;
+ public String qortalPartnerAddress;
@Schema(description = "Timestamp when AT switched to trade mode")
public Long tradeModeTimestamp;
- @Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)")
- public long tradeRefundTimeout;
+ @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)")
+ public Integer refundTimeout;
@Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)")
public Integer tradeRefundHeight;
@@ -54,10 +62,19 @@ public class CrossChainTradeData {
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long expectedBitcoin;
- public Mode mode;
+ public BTCACCT.Mode mode;
- @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout")
- public Integer lockTime;
+ @Schema(description = "Suggested Bitcoin P2SH-A nLockTime based on trade timeout")
+ public Integer lockTimeA;
+
+ @Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout")
+ public Integer lockTimeB;
+
+ @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)")
+ public byte[] partnerBitcoinPKH;
+
+ @Schema(description = "Trade partner's Qortal receiving address")
+ public String qortalPartnerReceivingAddress;
// Constructors
diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java
new file mode 100644
index 00000000..0f57845d
--- /dev/null
+++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java
@@ -0,0 +1,193 @@
+package org.qortal.data.crosschain;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
+
+import java.util.Map;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlTransient;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+// All properties to be converted to JSON via JAXB
+@XmlAccessorType(XmlAccessType.FIELD)
+public class TradeBotData {
+
+ private byte[] tradePrivateKey;
+
+ public enum State {
+ BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), BOB_REFUNDED(35),
+ ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_DONE(95), ALICE_REFUNDING_B(100), ALICE_REFUNDING_A(105), ALICE_REFUNDED(110);
+
+ public final int value;
+ private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
+
+ State(int value) {
+ this.value = value;
+ }
+
+ public static State valueOf(int value) {
+ return map.get(value);
+ }
+ }
+ private State tradeState;
+
+ private String creatorAddress;
+ private String atAddress;
+
+ private long timestamp;
+
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ private long qortAmount;
+
+ private byte[] tradeNativePublicKey;
+ private byte[] tradeNativePublicKeyHash;
+ String tradeNativeAddress;
+
+ private byte[] secret;
+ private byte[] hashOfSecret;
+
+ private byte[] tradeForeignPublicKey;
+ private byte[] tradeForeignPublicKeyHash;
+
+ @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
+ private long bitcoinAmount;
+
+ // Never expose this via API
+ @XmlTransient
+ @Schema(hidden = true)
+ private String xprv58;
+
+ private byte[] lastTransactionSignature;
+
+ private Integer lockTimeA;
+
+ // Could be Bitcoin or Qortal...
+ private byte[] receivingAccountInfo;
+
+ protected TradeBotData() {
+ /* JAXB */
+ }
+
+ public TradeBotData(byte[] tradePrivateKey, State tradeState, String creatorAddress, String atAddress,
+ long timestamp, long qortAmount,
+ byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
+ byte[] secret, byte[] hashOfSecret,
+ byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
+ long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) {
+ this.tradePrivateKey = tradePrivateKey;
+ this.tradeState = tradeState;
+ this.creatorAddress = creatorAddress;
+ this.atAddress = atAddress;
+ this.timestamp = timestamp;
+ this.qortAmount = qortAmount;
+ this.tradeNativePublicKey = tradeNativePublicKey;
+ this.tradeNativePublicKeyHash = tradeNativePublicKeyHash;
+ this.tradeNativeAddress = tradeNativeAddress;
+ this.secret = secret;
+ this.hashOfSecret = hashOfSecret;
+ this.tradeForeignPublicKey = tradeForeignPublicKey;
+ this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash;
+ this.bitcoinAmount = bitcoinAmount;
+ this.xprv58 = xprv58;
+ this.lastTransactionSignature = lastTransactionSignature;
+ this.lockTimeA = lockTimeA;
+ this.receivingAccountInfo = receivingAccountInfo;
+ }
+
+ public byte[] getTradePrivateKey() {
+ return this.tradePrivateKey;
+ }
+
+ public State getState() {
+ return this.tradeState;
+ }
+
+ public void setState(State state) {
+ this.tradeState = state;
+ }
+
+ public String getCreatorAddress() {
+ return this.creatorAddress;
+ }
+
+ public String getAtAddress() {
+ return this.atAddress;
+ }
+
+ public void setAtAddress(String atAddress) {
+ this.atAddress = atAddress;
+ }
+
+ public long getTimestamp() {
+ return this.timestamp;
+ }
+
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public long getQortAmount() {
+ return this.qortAmount;
+ }
+
+ public byte[] getTradeNativePublicKey() {
+ return this.tradeNativePublicKey;
+ }
+
+ public byte[] getTradeNativePublicKeyHash() {
+ return this.tradeNativePublicKeyHash;
+ }
+
+ public String getTradeNativeAddress() {
+ return this.tradeNativeAddress;
+ }
+
+ public byte[] getSecret() {
+ return this.secret;
+ }
+
+ public byte[] getHashOfSecret() {
+ return this.hashOfSecret;
+ }
+
+ public byte[] getTradeForeignPublicKey() {
+ return this.tradeForeignPublicKey;
+ }
+
+ public byte[] getTradeForeignPublicKeyHash() {
+ return this.tradeForeignPublicKeyHash;
+ }
+
+ public long getBitcoinAmount() {
+ return this.bitcoinAmount;
+ }
+
+ public String getXprv58() {
+ return this.xprv58;
+ }
+
+ public byte[] getLastTransactionSignature() {
+ return this.lastTransactionSignature;
+ }
+
+ public void setLastTransactionSignature(byte[] lastTransactionSignature) {
+ this.lastTransactionSignature = lastTransactionSignature;
+ }
+
+ public Integer getLockTimeA() {
+ return this.lockTimeA;
+ }
+
+ public void setLockTimeA(Integer lockTimeA) {
+ this.lockTimeA = lockTimeA;
+ }
+
+ public byte[] getReceivingAccountInfo() {
+ return this.receivingAccountInfo;
+ }
+
+}
diff --git a/src/main/java/org/qortal/event/Event.java b/src/main/java/org/qortal/event/Event.java
new file mode 100644
index 00000000..0c97522c
--- /dev/null
+++ b/src/main/java/org/qortal/event/Event.java
@@ -0,0 +1,5 @@
+package org.qortal.event;
+
+public interface Event {
+
+}
diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java
new file mode 100644
index 00000000..e0014a20
--- /dev/null
+++ b/src/main/java/org/qortal/event/EventBus.java
@@ -0,0 +1,33 @@
+package org.qortal.event;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public enum EventBus {
+ INSTANCE;
+
+ private static final List LISTENERS = new ArrayList<>();
+
+ public void addListener(Listener newListener) {
+ synchronized (LISTENERS) {
+ LISTENERS.add(newListener);
+ }
+ }
+
+ public void removeListener(Listener listener) {
+ synchronized (LISTENERS) {
+ LISTENERS.remove(listener);
+ }
+ }
+
+ public void notify(Event event) {
+ List clonedListeners;
+
+ synchronized (LISTENERS) {
+ clonedListeners = new ArrayList<>(LISTENERS);
+ }
+
+ for (Listener listener : clonedListeners)
+ listener.listen(event);
+ }
+}
diff --git a/src/main/java/org/qortal/event/Listener.java b/src/main/java/org/qortal/event/Listener.java
new file mode 100644
index 00000000..cb1668bf
--- /dev/null
+++ b/src/main/java/org/qortal/event/Listener.java
@@ -0,0 +1,6 @@
+package org.qortal.event;
+
+@FunctionalInterface
+public interface Listener {
+ void listen(Event event);
+}
diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java
index affbaf18..f3c2b16d 100644
--- a/src/main/java/org/qortal/repository/ATRepository.java
+++ b/src/main/java/org/qortal/repository/ATRepository.java
@@ -15,6 +15,9 @@ public interface ATRepository {
/** Returns where AT with passed address exists in repository */
public boolean exists(String atAddress) throws DataException;
+ /** Returns AT creator's public key, or null if not found */
+ public byte[] getCreatorPublicKey(String atAddress) throws DataException;
+
/** Returns list of executable ATs, empty if none found */
public List getAllExecutableATs() throws DataException;
@@ -54,6 +57,24 @@ public interface ATRepository {
*/
public ATStateData getLatestATState(String atAddress) throws DataException;
+ /**
+ * Returns final ATStateData for ATs matching codeHash (required)
+ * and specific data segment value (optional).
+ *
+ * If searching for specific data segment value, both dataByteOffset
+ * and expectedValue need to be non-null.
+ *
+ * Note that dataByteOffset starts from 0 and will typically be
+ * a multiple of MachineState.VALUE_SIZE, which is usually 8:
+ * width of a long.
+ *
+ * Although expectedValue, if provided, is natively an unsigned long,
+ * the data segment comparison is done via unsigned hex string.
+ */
+ public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished,
+ Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
+ Integer limit, Integer offset, Boolean reverse) throws DataException;
+
/**
* Returns all ATStateData for a given block height.
*
@@ -88,4 +109,28 @@ public interface ATRepository {
/** Delete state data for all ATs at this height */
public void deleteATStates(int height) throws DataException;
+ // Finding transactions for ATs to process
+
+ static class NextTransactionInfo {
+ public final int height;
+ public final int sequence;
+ public final byte[] signature;
+
+ public NextTransactionInfo(int height, int sequence, byte[] signature) {
+ this.height = height;
+ this.sequence = sequence;
+ this.signature = signature;
+ }
+ }
+
+ /**
+ * Find next transaction for AT to process.
+ *
+ * @param recipient AT address
+ * @param height starting height
+ * @param sequence starting sequence
+ * @return next transaction info, or null if none found
+ */
+ public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException;
+
}
diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java
index 8104edef..078128f6 100644
--- a/src/main/java/org/qortal/repository/BlockRepository.java
+++ b/src/main/java/org/qortal/repository/BlockRepository.java
@@ -61,6 +61,15 @@ public interface BlockRepository {
*/
public int getHeightFromTimestamp(long timestamp) throws DataException;
+ /**
+ * Returns block timestamp for a given height.
+ *
+ * @param height
+ * @return timestamp, or 0 if height is out of bounds.
+ * @throws DataException
+ */
+ public long getTimestampFromHeight(int height) throws DataException;
+
/**
* Return highest block height from repository.
*
diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java
new file mode 100644
index 00000000..cee1dc69
--- /dev/null
+++ b/src/main/java/org/qortal/repository/CrossChainRepository.java
@@ -0,0 +1,18 @@
+package org.qortal.repository;
+
+import java.util.List;
+
+import org.qortal.data.crosschain.TradeBotData;
+
+public interface CrossChainRepository {
+
+ public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException;
+
+ public List getAllTradeBotData() throws DataException;
+
+ public void save(TradeBotData tradeBotData) throws DataException;
+
+ /** Delete trade-bot states using passed private key. */
+ public int delete(byte[] tradePrivateKey) throws DataException;
+
+}
diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java
index 5c28253b..aecf4ef0 100644
--- a/src/main/java/org/qortal/repository/Repository.java
+++ b/src/main/java/org/qortal/repository/Repository.java
@@ -14,6 +14,8 @@ public interface Repository extends AutoCloseable {
public ChatRepository getChatRepository();
+ public CrossChainRepository getCrossChainRepository();
+
public GroupRepository getGroupRepository();
public NameRepository getNameRepository();
diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java
index 56e51be1..38856d24 100644
--- a/src/main/java/org/qortal/repository/TransactionRepository.java
+++ b/src/main/java/org/qortal/repository/TransactionRepository.java
@@ -6,6 +6,7 @@ import java.util.Map;
import org.qortal.api.resource.TransactionsResource.ConfirmationStatus;
import org.qortal.data.group.GroupApprovalData;
import org.qortal.data.transaction.GroupApprovalTransactionData;
+import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.data.transaction.TransferAssetTransactionData;
import org.qortal.transaction.Transaction.TransactionType;
@@ -107,6 +108,18 @@ public interface TransactionRepository {
*/
public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException;
+ /**
+ * Returns list of MESSAGE transaction data matching recipient.
+ * @param recipient
+ * @param limit
+ * @param offset
+ * @param reverse
+ * @return
+ * @throws DataException
+ */
+ public List getMessagesByRecipient(String recipient,
+ Integer limit, Integer offset, Boolean reverse) throws DataException;
+
/**
* Returns list of transactions relating to specific asset ID.
*
diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java
index f6de4fb4..e223b760 100644
--- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java
+++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java
@@ -68,6 +68,20 @@ public class HSQLDBATRepository implements ATRepository {
}
}
+ @Override
+ public byte[] getCreatorPublicKey(String atAddress) throws DataException {
+ String sql = "SELECT creator FROM ATs WHERE AT_address = ?";
+
+ try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) {
+ if (resultSet == null)
+ return null;
+
+ return resultSet.getBytes(1);
+ } catch (SQLException e) {
+ throw new DataException("Unable to fetch AT creator's public key from repository", e);
+ }
+ }
+
@Override
public List getAllExecutableATs() throws DataException {
String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, "
@@ -273,6 +287,78 @@ public class HSQLDBATRepository implements ATRepository {
}
}
+ @Override
+ public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished,
+ Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
+ Integer limit, Integer offset, Boolean reverse) throws DataException {
+ StringBuilder sql = new StringBuilder(1024);
+ sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial "
+ + "FROM ATs "
+ + "CROSS JOIN LATERAL("
+ + "SELECT height, created_when, state_data, state_hash, fees, is_initial "
+ + "FROM ATStates "
+ + "WHERE ATStates.AT_address = ATs.AT_address "
+ + "ORDER BY height DESC "
+ + "LIMIT 1"
+ + ") AS FinalATStates "
+ + "WHERE code_hash = ? ");
+
+ List