diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
index 99820022..f0b5d0d1 100644
--- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
+++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
@@ -14,7 +14,7 @@ public class CrossChainSecretRequest {
@Schema(description = "Qortal AT address")
public String atAddress;
- @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
+ @Schema(description = "secret-A + secret-B (64 bytes)", example = "2gt2nSVBFknLfdU5buKtScLuTibkt9C3x6PZVqnA3AJ6BdEf3A9RbSj5Hn5QkvavdTTfmttNEaYEVw34TZdz135Q")
public byte[] secret;
public CrossChainSecretRequest() {
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..2c319fd9
--- /dev/null
+++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java
@@ -0,0 +1,20 @@
+package org.qortal.api.model;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+@XmlAccessorType(XmlAccessType.FIELD)
+public class TradeBotRespondRequest {
+
+ @Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy")
+ public String atAddress;
+
+ @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
+ public String xprv58;
+
+ 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 92cf4096..d1cbcd8f 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java
@@ -21,7 +21,6 @@ import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
-import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
@@ -36,12 +35,13 @@ 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.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;
@@ -55,6 +55,7 @@ import org.qortal.crosschain.BTCP2SH;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.TradeBotData;
import org.qortal.data.crosschain.CrossChainTradeData.Mode;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData;
@@ -123,8 +124,6 @@ public class CrossChainResource {
}
return crossChainTradesData;
- } catch (ApiException e) {
- throw e;
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
@@ -152,6 +151,8 @@ 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)
@@ -245,6 +246,8 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) {
+ Security.checkApiCallAllowed(request);
+
byte[] creatorPublicKey = tradeRequest.creatorPublicKey;
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
@@ -277,8 +280,8 @@ public class CrossChainResource {
@POST
@Path("/tradeoffer/secret")
@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.
"
+ summary = "Builds raw, unsigned MESSAGE transaction that sends secrets to AT, releasing funds to recipient",
+ description = "Specify address of cross-chain AT that needs to be messaged, and both 32-byte secrets.
"
+ "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.",
requestBody = @RequestBody(
@@ -302,6 +305,8 @@ 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) {
+ Security.checkApiCallAllowed(request);
+
byte[] recipientPublicKey = secretRequest.recipientPublicKey;
if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
@@ -310,7 +315,7 @@ public class CrossChainResource {
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.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH * 2)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
try (final Repository repository = RepositoryManager.getRepository()) {
@@ -365,6 +370,8 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) {
+ Security.checkApiCallAllowed(request);
+
byte[] creatorPublicKey = cancelRequest.creatorPublicKey;
if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
@@ -415,6 +422,8 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
}
@@ -439,6 +448,8 @@ public class CrossChainResource {
)
@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);
}
@@ -494,6 +505,8 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE})
public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) {
+ Security.checkApiCallAllowed(request);
+
return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
}
@@ -518,6 +531,8 @@ public class CrossChainResource {
)
@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);
}
@@ -607,6 +622,8 @@ 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 refundP2shA(CrossChainBitcoinRefundRequest refundRequest) {
+ Security.checkApiCallAllowed(request);
+
return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
}
@@ -632,6 +649,8 @@ 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 refundP2shB(CrossChainBitcoinRefundRequest refundRequest) {
+ Security.checkApiCallAllowed(request);
+
return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
}
@@ -716,6 +735,7 @@ public class CrossChainResource {
@Path("/p2sh/a/redeem")
@Operation(
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(
@@ -734,6 +754,8 @@ 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 redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) {
+ Security.checkApiCallAllowed(request);
+
return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA);
}
@@ -741,6 +763,7 @@ public class CrossChainResource {
@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(
@@ -759,6 +782,8 @@ 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 redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) {
+ Security.checkApiCallAllowed(request);
+
return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB);
}
@@ -845,8 +870,35 @@ public class CrossChainResource {
}
}
- @POST
+ @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(
@@ -866,6 +918,8 @@ public class CrossChainResource {
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
+ Security.checkApiCallAllowed(request);
+
if (tradeBotCreateRequest.tradeTimeout < 600)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
@@ -884,9 +938,19 @@ public class CrossChainResource {
}
@POST
- @Path("/tradebot/{ataddress}")
+ @Path("/tradebot/respond")
@Operation(
- summary = "Respond to a trade offer",
+ 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"))
@@ -894,10 +958,24 @@ public class CrossChainResource {
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE})
- public String tradeBotResponder(@PathParam("ataddress") String atAddress) {
+ 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);
+ final byte[] xprv;
+ try {
+ xprv = Base58.decode(tradeBotRespondRequest.xprv58);
+
+ if (xprv.length != 4 + 1 + 4 + 4 + 32 + 33 + 4)
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+ } catch (NumberFormatException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
+ }
+
// Extract data from cross-chain trading AT
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = fetchAtDataWithChecking(repository, null, atAddress); // null to skip creator check
@@ -906,11 +984,58 @@ public class CrossChainResource {
if (crossChainTradeData.mode != Mode.OFFER)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
- String p2shAddress = TradeBot.startResponse(repository, crossChainTradeData);
- if (p2shAddress == null)
- throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE);
+ boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58);
- return p2shAddress;
+ return result ? "true" : "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.getState() != TradeBotData.State.ALICE_DONE && tradeBotData.getState() != TradeBotData.State.BOB_DONE)
+ return "false";
+
+ repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
+ repository.saveChanges();
+
+ return "true";
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java
index 18ada4b8..61dbb39c 100644
--- a/src/main/java/org/qortal/controller/TradeBot.java
+++ b/src/main/java/org/qortal/controller/TradeBot.java
@@ -8,7 +8,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.bitcoinj.core.Coin;
import org.bitcoinj.core.ECKey;
+import org.bitcoinj.core.Transaction;
+import org.bitcoinj.core.TransactionOutput;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.account.PublicKeyAccount;
import org.qortal.api.model.TradeBotCreateRequest;
@@ -38,7 +41,8 @@ public class TradeBot {
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. */
@@ -61,7 +65,7 @@ public class TradeBot {
byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey);
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
- String tradeAddress = Crypto.toAddress(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey);
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
@@ -79,7 +83,7 @@ public class TradeBot {
String description = "QORT/BTC cross-chain trade";
String aTType = "ACCT";
String tags = "ACCT QORT BTC";
- byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout);
+ 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);
@@ -93,9 +97,10 @@ public class TradeBot {
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM,
atAddress,
- tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretB, hashOfSecretB,
tradeForeignPublicKey, tradeForeignPublicKeyHash,
- tradeBotCreateRequest.bitcoinAmount, null, null);
+ tradeBotCreateRequest.bitcoinAmount, null, null, null);
repository.getCrossChainRepository().save(tradeBotData);
repository.saveChanges();
@@ -107,13 +112,14 @@ public class TradeBot {
}
}
- public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
+ public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58) 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);
@@ -123,15 +129,36 @@ public class TradeBot {
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A,
crossChainTradeData.qortalAtAddress,
- tradeNativePublicKey, tradeNativePublicKeyHash, secretA, hashOfSecretA,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretA, hashOfSecretA,
tradeForeignPublicKey, tradeForeignPublicKeyHash,
- crossChainTradeData.expectedBitcoin, null, lockTimeA);
- repository.getCrossChainRepository().save(tradeBotData);
- repository.saveChanges();
+ crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA);
+
+ // Check we have enough funds via xprv58 to fund both P2SH 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 false;
// P2SH_a to be funded
byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA);
- return BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+ String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // Fund P2SH-a
+ Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT);
+ 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 false;
+ }
+
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ return true;
}
private static byte[] generateTradePrivateKey() {
@@ -175,12 +202,32 @@ public class TradeBot {
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_P2SH_A:
- handleAliceWaitingForP2shA(repository, tradeBotData);
+ 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;
default:
@@ -203,6 +250,48 @@ public class TradeBot {
repository.saveChanges();
}
+ 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);
+
+ 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;
+ }
+
+ // 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);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+ }
+
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());
@@ -211,7 +300,7 @@ public class TradeBot {
return;
}
- String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey());
+ String address = tradeBotData.getTradeNativeAddress();
List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null);
final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature();
@@ -231,8 +320,6 @@ public class TradeBot {
if (messageTransactionData.isText())
continue;
- // Could enforce encryption here
-
// We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash
byte[] messageData = messageTransactionData.getData();
BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData);
@@ -286,7 +373,9 @@ public class TradeBot {
}
}
- private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException {
+ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException {
+ // XXX REFUND CHECK
+
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
if (atData == null) {
LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress()));
@@ -294,18 +383,149 @@ public class TradeBot {
}
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
+ // We're waiting for AT to be in TRADE mode
+ if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE)
+ return;
+
+ // We're expecting AT to be locked to our native trade address
+ if (!crossChainTradeData.qortalRecipient.equals(tradeBotData.getTradeNativeAddress())) {
+ // AT locked to different address! We shouldn't continue but wait and refund.
+ LOGGER.warn(() -> String.format("Trade AT '%s' locked to '%s', not us ('%s')",
+ tradeBotData.getAtAddress(),
+ crossChainTradeData.qortalRecipient,
+ tradeBotData.getTradeNativeAddress()));
+
+ // There's no P2SH-b at this point, so jump straight to refunding P2SH-a
+ tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ 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 lockTimeB should match AT's calculated lockTimeB
+ if (lockTimeB != crossChainTradeData.lockTimeB) {
+ LOGGER.debug(() -> String.format("Trade AT lockTimeB '%d' doesn't match our lockTimeB '%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 (!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);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+ }
+
+ private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException {
+ // XXX REFUND CHECK
+
+ 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);
+
+ // 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.recipientBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
Long balance = BTC.getInstance().getBalance(p2shAddress);
- if (balance == null || balance < crossChainTradeData.expectedBitcoin)
+ 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);
+
+ Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret());
+
+ 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 to redeem AT
+ tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+ }
+
+ private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException {
+ // XXX REFUND CHECK
+
+ 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);
+
+ 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;
- // Attempt to send MESSAGE to Bob's Qortal trade address
- byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
+ // Send MESSAGE to AT using both secrets
+ byte[] secretA = tradeBotData.getSecret();
+ byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB);
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
- MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false);
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false);
messageTransaction.computeNonce();
messageTransaction.sign(sender);
@@ -319,7 +539,50 @@ public class TradeBot {
return;
}
- tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK);
+ tradeBotData.setState(TradeBotData.State.ALICE_DONE);
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+ }
+
+ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException {
+ // XXX REFUND CHECK
+
+ 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;
+
+ 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 secretA to redeem P2SH-a
+
+ byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, 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);
+
+ Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA);
+
+ 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);
repository.getCrossChainRepository().save(tradeBotData);
repository.saveChanges();
}
diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java
index 6bf00073..6b350349 100644
--- a/src/main/java/org/qortal/crosschain/BTC.java
+++ b/src/main/java/org/qortal/crosschain/BTC.java
@@ -1,26 +1,42 @@
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 {
@@ -60,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() {
@@ -121,9 +140,10 @@ public class BTC {
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);
}
@@ -132,17 +152,17 @@ public class BTC {
}
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;
@@ -157,6 +177,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));
}
@@ -165,6 +186,147 @@ 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));
+
+ DeterministicKeyChain activeKeyChain = wallet.getActiveKeyChain();
+ activeKeyChain.setLookaheadSize(3);
+
+ 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);
+
+ do {
+ activeKeyChain.maybeLookAhead();
+
+ try {
+ wallet.completeTx(sendRequest);
+ break;
+ } catch (InsufficientMoneyException e) {
+ return null;
+ } catch (WalletAwareUTXOProvider.AllKeysSpentException e) {
+ // loop again and use maybeLookAhead() to generate more keys to check
+ }
+ } while (true);
+
+ return sendRequest.tx;
+ }
+
+ // UTXOProvider support
+
+ static class WalletAwareUTXOProvider implements UTXOProvider {
+ private final Wallet wallet;
+ private final BTC btc;
+
+ // We extend RuntimeException for unchecked-ness so it will bubble up to caller.
+ // We can't use UTXOProviderException as it will be wrapped in RuntimeException anyway.
+ @SuppressWarnings("serial")
+ public static class AllKeysSpentException extends RuntimeException {
+ public AllKeysSpentException() {
+ super();
+ }
+ }
+
+ public WalletAwareUTXOProvider(BTC btc, Wallet wallet) {
+ this.btc = btc;
+ this.wallet = wallet;
+ }
+
+ public List getOpenTransactionOutputs(List keys) throws UTXOProviderException {
+ List allUnspentOutputs = new ArrayList<>();
+ final boolean coinbase = false;
+
+ boolean areAllKeysSpent = true;
+ for (ECKey key : keys) {
+ if (btc.spentKeys.contains(key)) {
+ wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
+ continue;
+ }
+
+ 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 all passed keys are spent then we need to signal caller that they might want to
+ * generate more keys to check.
+ */
+
+ if (unspentOutputs.isEmpty()) {
+ // 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);
+ }
+
+ 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 (areAllKeysSpent)
+ // Notify caller that they need to check more keys
+ throw new AllKeysSpentException();
+
+ 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 ad185d87..3910bfa4 100644
--- a/src/main/java/org/qortal/crosschain/BTCACCT.java
+++ b/src/main/java/org/qortal/crosschain/BTCACCT.java
@@ -4,6 +4,7 @@ import static org.ciyam.at.OpCode.calcOffset;
import java.nio.ByteBuffer;
import java.util.Arrays;
+import java.util.List;
import org.ciyam.at.API;
import org.ciyam.at.CompilationException;
@@ -19,6 +20,7 @@ import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.utils.Base58;
@@ -747,4 +749,49 @@ public class BTCACCT {
return (int) ((lockTimeA + (recipientMessageTimestamp / 1000L)) / 2L);
}
+ public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
+ String atAddress = crossChainTradeData.qortalAtAddress;
+ String redeemerAddress = crossChainTradeData.qortalRecipient;
+
+ 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 != 32 + 32)
+ // 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/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java
index 41c3d99d..0c5213f5 100644
--- a/src/main/java/org/qortal/crosschain/ElectrumX.java
+++ b/src/main/java/org/qortal/crosschain/ElectrumX.java
@@ -25,7 +25,6 @@ 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;
@@ -166,7 +165,21 @@ public class ElectrumX {
return (Long) balanceJson.get("confirmed");
}
- public List> getUnspentOutputs(byte[] script) {
+ 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;
+ }
+ }
+
+ public List getUnspentOutputs(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
@@ -174,14 +187,16 @@ public class ElectrumX {
if (unspentJson == null)
return null;
- List> unspentOutputs = new ArrayList<>();
+ List unspentOutputs = new ArrayList<>();
for (Object rawUnspent : unspentJson) {
JSONObject unspent = (JSONObject) rawUnspent;
byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes();
int outputIndex = ((Long) unspent.get("tx_pos")).intValue();
+ int height = ((Long) unspent.get("height")).intValue();
+ long value = (Long) unspent.get("value");
- unspentOutputs.add(new Pair<>(txHash, outputIndex));
+ unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value));
}
return unspentOutputs;
@@ -195,6 +210,7 @@ public class ElectrumX {
return HashCode.fromString(rawTransactionHex).asBytes();
}
+ /** Returns list of raw transactions. */
public List getAddressTransactions(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java
index 4441212c..8a77d80f 100644
--- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java
+++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java
@@ -15,14 +15,11 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class TradeBotData {
- // Never expose this
- @XmlTransient
- @Schema(hidden = true)
private byte[] tradePrivateKey;
public enum State {
- BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50),
- ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(90), ALICE_WATCH_P2SH_B(100);
+ 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),
+ ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_REFUNDING_B(95), ALICE_REFUNDING_A(100), ALICE_DONE(105);
public final int value;
private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
@@ -41,6 +38,7 @@ public class TradeBotData {
private byte[] tradeNativePublicKey;
private byte[] tradeNativePublicKeyHash;
+ String tradeNativeAddress;
private byte[] secret;
private byte[] hashOfSecret;
@@ -50,24 +48,36 @@ public class TradeBotData {
private long bitcoinAmount;
+ // Never expose this
+ @XmlTransient
+ @Schema(hidden = true)
+ private String xprv58;
+
private byte[] lastTransactionSignature;
private Integer lockTimeA;
+ protected TradeBotData() {
+ /* JAXB */
+ }
+
public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress,
- byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] hashOfSecret,
+ byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
+ byte[] secret, byte[] hashOfSecret,
byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
- long bitcoinAmount, byte[] lastTransactionSignature, Integer lockTimeA) {
+ long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA) {
this.tradePrivateKey = tradePrivateKey;
this.tradeState = tradeState;
this.atAddress = atAddress;
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;
}
@@ -100,6 +110,10 @@ public class TradeBotData {
return this.tradeNativePublicKeyHash;
}
+ public String getTradeNativeAddress() {
+ return this.tradeNativeAddress;
+ }
+
public byte[] getSecret() {
return this.secret;
}
@@ -120,6 +134,10 @@ public class TradeBotData {
return this.bitcoinAmount;
}
+ public String getXprv58() {
+ return this.xprv58;
+ }
+
public byte[] getLastTransactionSignature() {
return this.lastTransactionSignature;
}
diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java
index e1b409a0..cee1dc69 100644
--- a/src/main/java/org/qortal/repository/CrossChainRepository.java
+++ b/src/main/java/org/qortal/repository/CrossChainRepository.java
@@ -6,8 +6,13 @@ 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/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java
index 392f42b1..3c30444e 100644
--- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java
+++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java
@@ -17,13 +17,58 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
this.repository = repository;
}
+ @Override
+ public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException {
+ String sql = "SELECT trade_state, at_address, "
+ + "trade_native_public_key, trade_native_public_key_hash, "
+ + "trade_native_address, secret, hash_of_secret, "
+ + "trade_foreign_public_key, trade_foreign_public_key_hash, "
+ + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a "
+ + "FROM TradeBotStates "
+ + "WHERE trade_private_key = ?";
+
+ try (ResultSet resultSet = this.repository.checkedExecute(sql, tradePrivateKey)) {
+ if (resultSet == null)
+ return null;
+
+ int tradeStateValue = resultSet.getInt(1);
+ TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue);
+ if (tradeState == null)
+ throw new DataException("Illegal trade-bot trade-state fetched from repository");
+
+ String atAddress = resultSet.getString(2);
+ byte[] tradeNativePublicKey = resultSet.getBytes(3);
+ byte[] tradeNativePublicKeyHash = resultSet.getBytes(4);
+ String tradeNativeAddress = resultSet.getString(5);
+ byte[] secret = resultSet.getBytes(6);
+ byte[] hashOfSecret = resultSet.getBytes(7);
+ byte[] tradeForeignPublicKey = resultSet.getBytes(8);
+ byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9);
+ long bitcoinAmount = resultSet.getLong(10);
+ String xprv58 = resultSet.getString(11);
+ byte[] lastTransactionSignature = resultSet.getBytes(12);
+ Integer lockTimeA = resultSet.getInt(13);
+ if (lockTimeA == 0 && resultSet.wasNull())
+ lockTimeA = null;
+
+ return new TradeBotData(tradePrivateKey, tradeState,
+ atAddress,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secret, hashOfSecret,
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA);
+ } catch (SQLException e) {
+ throw new DataException("Unable to fetch trade-bot trading state from repository", e);
+ }
+ }
+
@Override
public List getAllTradeBotData() throws DataException {
String sql = "SELECT trade_private_key, trade_state, at_address, "
+ "trade_native_public_key, trade_native_public_key_hash, "
- + "secret, hash_of_secret, "
+ + "trade_native_address, secret, hash_of_secret, "
+ "trade_foreign_public_key, trade_foreign_public_key_hash, "
- + "bitcoin_amount, last_transaction_signature, locktime_a "
+ + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a "
+ "FROM TradeBotStates";
List allTradeBotData = new ArrayList<>();
@@ -42,21 +87,24 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
String atAddress = resultSet.getString(3);
byte[] tradeNativePublicKey = resultSet.getBytes(4);
byte[] tradeNativePublicKeyHash = resultSet.getBytes(5);
- byte[] secret = resultSet.getBytes(6);
- byte[] hashOfSecret = resultSet.getBytes(7);
- byte[] tradeForeignPublicKey = resultSet.getBytes(8);
- byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9);
- long bitcoinAmount = resultSet.getLong(10);
- byte[] lastTransactionSignature = resultSet.getBytes(11);
- Integer lockTimeA = resultSet.getInt(12);
+ String tradeNativeAddress = resultSet.getString(6);
+ byte[] secret = resultSet.getBytes(7);
+ byte[] hashOfSecret = resultSet.getBytes(8);
+ byte[] tradeForeignPublicKey = resultSet.getBytes(9);
+ byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10);
+ long bitcoinAmount = resultSet.getLong(11);
+ String xprv58 = resultSet.getString(12);
+ byte[] lastTransactionSignature = resultSet.getBytes(13);
+ Integer lockTimeA = resultSet.getInt(14);
if (lockTimeA == 0 && resultSet.wasNull())
lockTimeA = null;
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState,
atAddress,
- tradeNativePublicKey, tradeNativePublicKeyHash, secret, hashOfSecret,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secret, hashOfSecret,
tradeForeignPublicKey, tradeForeignPublicKeyHash,
- bitcoinAmount, lastTransactionSignature, lockTimeA);
+ bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA);
allTradeBotData.add(tradeBotData);
} while (resultSet.next());
@@ -73,14 +121,16 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey())
.bind("trade_state", tradeBotData.getState().value)
.bind("at_address", tradeBotData.getAtAddress())
- .bind("locktime_a", tradeBotData.getLockTimeA())
.bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey())
.bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash())
+ .bind("trade_native_address", tradeBotData.getTradeNativeAddress())
.bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret())
.bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey())
.bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash())
.bind("bitcoin_amount", tradeBotData.getBitcoinAmount())
- .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature());
+ .bind("xprv58", tradeBotData.getXprv58())
+ .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature())
+ .bind("locktime_a", tradeBotData.getLockTimeA());
try {
saveHelper.execute(this.repository);
@@ -89,4 +139,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
}
}
-}
+ @Override
+ public int delete(byte[] tradePrivateKey) throws DataException {
+ try {
+ return this.repository.delete("TradeBotStates", "trade_private_key = ?", tradePrivateKey);
+ } catch (SQLException e) {
+ throw new DataException("Unable to delete trade-bot states from repository", e);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java
index df08efcb..3ea10454 100644
--- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java
+++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java
@@ -623,9 +623,9 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, "
+ "at_address QortalAddress, "
+ "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, "
- + "secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, "
+ + "trade_native_address QortalAddress NOT NULL, secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, "
+ "trade_foreign_public_key VARBINARY(33) NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, "
- + "bitcoin_amount BIGINT NOT NULL, last_transaction_signature Signature, locktime_a BIGINT, "
+ + "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, "
+ "PRIMARY KEY (trade_private_key))");
break;
diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java
index b9f7869a..1b6123a7 100644
--- a/src/test/java/org/qortal/test/btcacct/BtcTests.java
+++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java
@@ -62,4 +62,15 @@ public class BtcTests extends Common {
assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret));
}
+ @Test
+ public void testBuildSpend() {
+ BTC btc = BTC.getInstance();
+
+ String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
+ String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
+ long amount = 1000L;
+
+ btc.buildSpend(xprv58, recipient, amount);
+ }
+
}
diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java
index 3a958c79..992af2ee 100644
--- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java
+++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java
@@ -12,8 +12,8 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
import org.junit.Test;
import org.qortal.crosschain.ElectrumX;
+import org.qortal.crosschain.ElectrumX.UnspentOutput;
import org.qortal.utils.BitTwiddling;
-import org.qortal.utils.Pair;
import com.google.common.hash.HashCode;
@@ -100,13 +100,13 @@ public class ElectrumXTests {
Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF");
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
- List> unspentOutputs = electrumX.getUnspentOutputs(script);
+ List unspentOutputs = electrumX.getUnspentOutputs(script);
assertNotNull(unspentOutputs);
assertFalse(unspentOutputs.isEmpty());
- for (Pair unspentOutput : unspentOutputs)
- System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.getA()).toString(), unspentOutput.getB()));
+ for (UnspentOutput unspentOutput : unspentOutputs)
+ System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.hash), unspentOutput.index));
}
@Test