Browse Source

WIP: trade-bot now does complete end-to-end trade (more work needed)

bitcoinj now uses ElectrumX as an UTXO provider in order to keep track
of coins in BIP32 deterministic wallet.

Trade responder (Alice) needs to pass a BIP32 extended private key to API
so trade-bot can create unattended spends.

Both Alice and Bob can find their final funds in accounts using the
ephemeral 'tradePrivateKey' from trade-bot state data.

Most cross-chain API calls are now only allowed from localhost.

Most Bitcoin fees pegged at 0.00001000 BTC.

More work needed to handle refunds in case of trade failures.
(See XXX comment tags in TradeBot.java)
split-DB
catbref 4 years ago
parent
commit
579645d6b7
  1. 2
      src/main/java/org/qortal/api/model/CrossChainSecretRequest.java
  2. 20
      src/main/java/org/qortal/api/model/TradeBotRespondRequest.java
  3. 155
      src/main/java/org/qortal/api/resource/CrossChainResource.java
  4. 309
      src/main/java/org/qortal/controller/TradeBot.java
  5. 174
      src/main/java/org/qortal/crosschain/BTC.java
  6. 47
      src/main/java/org/qortal/crosschain/BTCACCT.java
  7. 24
      src/main/java/org/qortal/crosschain/ElectrumX.java
  8. 32
      src/main/java/org/qortal/data/crosschain/TradeBotData.java
  9. 5
      src/main/java/org/qortal/repository/CrossChainRepository.java
  10. 87
      src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java
  11. 4
      src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java
  12. 11
      src/test/java/org/qortal/test/btcacct/BtcTests.java
  13. 8
      src/test/java/org/qortal/test/btcacct/ElectrumXTests.java

2
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() {

20
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() {
}
}

155
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.<br>"
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.<br>"
+ "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
+ "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<TradeBotData> 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 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 p2shAddress;
return "true";
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}

309
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<MessageTransactionData> 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<MessageTransactionData> 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;
}
// Attempt to send MESSAGE to Bob's Qortal trade address
byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
// Redeem P2SH-b using secret-b
Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-a
ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
List<TransactionOutput> 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<byte[]> 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 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<TransactionOutput> 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();
}

174
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<ECKey> spentKeys = new HashSet<>();
// Constructors and instance
private BTC() {
@ -121,9 +140,10 @@ public class BTC {
List<Integer> 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<TransactionOutput> getUnspentOutputs(String base58Address) {
List<Pair<byte[], Integer>> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
List<UnspentOutput> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
if (unspentOutputs == null)
return null;
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
for (Pair<byte[], Integer> unspentOutput : unspentOutputs) {
List<TransactionOutput> transactionOutputs = getOutputs(unspentOutput.getA());
for (UnspentOutput unspentOutput : unspentOutputs) {
List<TransactionOutput> 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<byte[]> 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 <tt>amount</tt> to <tt>recipient</tt>.
*
* @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<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
List<UTXO> 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<UnspentOutput> 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<byte[]> 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<TransactionOutput> 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) {

47
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<MessageTransactionData> 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;
}
}

24
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<Pair<byte[], Integer>> 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<UnspentOutput> getUnspentOutputs(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
@ -174,14 +187,16 @@ public class ElectrumX {
if (unspentJson == null)
return null;
List<Pair<byte[], Integer>> unspentOutputs = new ArrayList<>();
List<UnspentOutput> 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<byte[]> getAddressTransactions(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);

32
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<Integer, State> 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;
}

5
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<TradeBotData> 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;
}

87
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<TradeBotData> 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<TradeBotData> 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);
}
}
}

4
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;

11
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);
}
}

8
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<Pair<byte[], Integer>> unspentOutputs = electrumX.getUnspentOutputs(script);
List<UnspentOutput> unspentOutputs = electrumX.getUnspentOutputs(script);
assertNotNull(unspentOutputs);
assertFalse(unspentOutputs.isEmpty());
for (Pair<byte[], Integer> 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

Loading…
Cancel
Save