transactions = repository.getTransactionRepository().getUnconfirmedTransactions();
- for (TransactionData transactionData : transactions)
- if (now >= Transaction.getDeadline(transactionData)) {
- LOGGER.info(String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
+ for (TransactionData transactionData : transactions) {
+ Transaction transaction = Transaction.fromData(repository, transactionData);
+
+ if (now >= transaction.getDeadline()) {
+ LOGGER.info(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
repository.getTransactionRepository().delete(transactionData);
}
+ }
repository.saveChanges();
} catch (DataException e) {
@@ -1032,11 +1036,31 @@ public class Controller extends Thread {
}
}
- /** Callback for when we've received a new transaction via API or peer. */
- public void onNewTransaction(TransactionData transactionData, Peer peer) {
+ public static class NewTransactionEvent implements Event {
+ private final TransactionData transactionData;
+
+ public NewTransactionEvent(TransactionData transactionData) {
+ this.transactionData = transactionData;
+ }
+
+ public TransactionData getTransactionData() {
+ return this.transactionData;
+ }
+ }
+
+ /**
+ * Callback for when we've received a new transaction via API or peer.
+ *
+ * @implSpec performs actions in a new thread
+ */
+ public void onNewTransaction(TransactionData transactionData) {
this.callbackExecutor.execute(() -> {
- // Notify all peers (except maybe peer that sent it to us if applicable)
- Network.getInstance().broadcast(broadcastPeer -> broadcastPeer == peer ? null : new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature())));
+ // Notify all peers
+ Message newTransactionSignatureMessage = new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature()));
+ Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage);
+
+ // Notify listeners
+ EventBus.INSTANCE.notify(new NewTransactionEvent(transactionData));
// If this is a CHAT transaction, there may be extra listeners to notify
if (transactionData.getType() == TransactionType.CHAT)
@@ -1215,9 +1239,6 @@ public class Controller extends Thread {
} catch (DataException e) {
LOGGER.error(String.format("Repository issue while processing transaction %s from peer %s", Base58.encode(transactionData.getSignature()), peer), e);
}
-
- // Notify controller so it can notify other peers, etc.
- Controller.getInstance().onNewTransaction(transactionData, peer);
}
private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) {
diff --git a/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java
new file mode 100644
index 00000000..51b2b075
--- /dev/null
+++ b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java
@@ -0,0 +1,30 @@
+package org.qortal.controller.tradebot;
+
+import java.util.List;
+
+import org.qortal.api.model.crosschain.TradeBotCreateRequest;
+import org.qortal.crosschain.ACCT;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.data.at.ATData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+
+public interface AcctTradeBot {
+
+ public enum ResponseResult { OK, BALANCE_ISSUE, NETWORK_ISSUE, TRADE_ALREADY_EXISTS }
+
+ /** Returns list of state names for trade-bot entries that have ended, e.g. redeemed, refunded or cancelled. */
+ public List getEndStates();
+
+ public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException;
+
+ public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
+ CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException;
+
+ public boolean canDelete(Repository repository, TradeBotData tradeBotData);
+
+ public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException;
+
+}
diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java
similarity index 53%
rename from src/main/java/org/qortal/controller/TradeBot.java
rename to src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java
index e5494675..fe0f41c1 100644
--- a/src/main/java/org/qortal/controller/TradeBot.java
+++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java
@@ -1,14 +1,15 @@
-package org.qortal.controller;
+package org.qortal.controller.tradebot;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
-import java.awt.TrayIcon.MessageType;
-import java.security.SecureRandom;
import java.util.Arrays;
import java.util.List;
-import java.util.Random;
+import java.util.Map;
+import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
-import org.apache.logging.log4j.util.Supplier;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.Coin;
@@ -18,35 +19,30 @@ import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.script.Script.ScriptType;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.account.PublicKeyAccount;
-import org.qortal.api.model.TradeBotCreateRequest;
+import org.qortal.api.model.crosschain.TradeBotCreateRequest;
import org.qortal.asset.Asset;
-import org.qortal.crosschain.BTC;
-import org.qortal.crosschain.BTCACCT;
-import org.qortal.crosschain.BTCP2SH;
-import org.qortal.crosschain.BitcoinException;
+import org.qortal.crosschain.ACCT;
+import org.qortal.crosschain.AcctMode;
+import org.qortal.crosschain.Bitcoin;
+import org.qortal.crosschain.BitcoinACCTv1;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.SupportedBlockchain;
+import org.qortal.crosschain.BitcoinyHTLC;
import org.qortal.crypto.Crypto;
-import org.qortal.data.account.AccountBalanceData;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
-import org.qortal.event.Event;
-import org.qortal.event.EventBus;
-import org.qortal.event.Listener;
import org.qortal.group.Group;
-import org.qortal.gui.SysTray;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
-import org.qortal.repository.RepositoryManager;
-import org.qortal.settings.Settings;
import org.qortal.transaction.DeployAtTransaction;
import org.qortal.transaction.MessageTransaction;
import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
-import org.qortal.utils.Amounts;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
@@ -56,47 +52,84 @@ import org.qortal.utils.NTP;
* We deal with three different independent state-spaces here:
*
* - Qortal blockchain
- * - Bitcoin blockchain
+ * - Foreign blockchain
* - Trade-bot entries
*
*/
-public class TradeBot implements Listener {
+public class BitcoinACCTv1TradeBot implements AcctTradeBot {
- public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE }
+ private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv1TradeBot.class);
- public static class StateChangeEvent implements Event {
- private final TradeBotData tradeBotData;
+ public enum State implements TradeBot.StateNameAndValueSupplier {
+ BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
+ BOB_WAITING_FOR_MESSAGE(15, true, true),
+ BOB_WAITING_FOR_P2SH_B(20, true, true),
+ BOB_WAITING_FOR_AT_REDEEM(25, true, true),
+ BOB_DONE(30, false, false),
+ BOB_REFUNDED(35, false, false),
- public StateChangeEvent(TradeBotData tradeBotData) {
- this.tradeBotData = tradeBotData;
+ ALICE_WAITING_FOR_P2SH_A(80, true, true),
+ ALICE_WAITING_FOR_AT_LOCK(85, true, true),
+ ALICE_WATCH_P2SH_B(90, true, true),
+ ALICE_DONE(95, false, false),
+ ALICE_REFUNDING_B(100, true, true),
+ ALICE_REFUNDING_A(105, true, true),
+ ALICE_REFUNDED(110, false, false);
+
+ private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
+
+ public final int value;
+ public final boolean requiresAtData;
+ public final boolean requiresTradeData;
+
+ State(int value, boolean requiresAtData, boolean requiresTradeData) {
+ this.value = value;
+ this.requiresAtData = requiresAtData;
+ this.requiresTradeData = requiresTradeData;
}
- public TradeBotData getTradeBotData() {
- return this.tradeBotData;
+ public static State valueOf(int value) {
+ return map.get(value);
+ }
+
+ @Override
+ public String getState() {
+ return this.name();
+ }
+
+ @Override
+ public int getStateValue() {
+ return this.value;
}
}
- private static final Logger LOGGER = LogManager.getLogger(TradeBot.class);
- private static final Random RANDOM = new SecureRandom();
-
- /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. */
+ /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms
- private static final long P2SH_B_OUTPUT_AMOUNT = 1000L; // P2SH-B output amount needs to be higher than the dust threshold (3000 sats/kB).
+ /** P2SH-B output amount to avoid dust threshold (3000 sats/kB). */
+ private static final long P2SH_B_OUTPUT_AMOUNT = 1000L;
- private static TradeBot instance;
+ private static BitcoinACCTv1TradeBot instance;
- private TradeBot() {
- EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
+ private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDING_B, State.ALICE_REFUNDED).stream()
+ .map(State::name)
+ .collect(Collectors.toUnmodifiableList());
+
+ private BitcoinACCTv1TradeBot() {
}
- public static synchronized TradeBot getInstance() {
+ public static synchronized BitcoinACCTv1TradeBot getInstance() {
if (instance == null)
- instance = new TradeBot();
+ instance = new BitcoinACCTv1TradeBot();
return instance;
}
+ @Override
+ public List getEndStates() {
+ return this.endStates;
+ }
+
/**
* Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC.
*
@@ -129,22 +162,22 @@ public class TradeBot implements Listener {
* @return raw, unsigned DEPLOY_AT transaction
* @throws DataException
*/
- public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
- byte[] tradePrivateKey = generateTradePrivateKey();
- byte[] secretB = generateSecret();
+ public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+ byte[] secretB = TradeBot.generateSecret();
byte[] hashOfSecretB = Crypto.hash160(secretB);
- byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
- byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
// Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time)
Address bitcoinReceivingAddress;
try {
- bitcoinReceivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
+ bitcoinReceivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
} catch (AddressFormatException e) {
throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress);
}
@@ -166,8 +199,8 @@ public class TradeBot implements Listener {
String description = "QORT/BTC cross-chain trade";
String aTType = "ACCT";
String tags = "ACCT QORT BTC";
- byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount,
- tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout);
+ byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount,
+ tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
long amount = tradeBotCreateRequest.fundingQortAmount;
DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT);
@@ -179,15 +212,16 @@ public class TradeBot implements Listener {
DeployAtTransaction.ensureATAddress(deployAtTransactionData);
String atAddress = deployAtTransactionData.getAtAddress();
- TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM,
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME,
+ State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value,
creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secretB, hashOfSecretB,
+ SupportedBlockchain.BITCOIN.name(),
tradeForeignPublicKey, tradeForeignPublicKeyHash,
- tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo);
+ tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo);
- updateTradeBotState(repository, tradeBotData, tradeBotData.getState(),
- () -> String.format("Built AT %s. Waiting for deployment", atAddress));
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
// Return to user for signing and broadcast as we don't have their Qortal private key
try {
@@ -235,168 +269,180 @@ public class TradeBot implements Listener {
* @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise
* @throws DataException
*/
- public static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
- byte[] tradePrivateKey = generateTradePrivateKey();
- byte[] secretA = generateSecret();
+ public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+ byte[] secretA = TradeBot.generateSecret();
byte[] hashOfSecretA = Crypto.hash160(secretA);
- byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
- byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH
// We need to generate lockTime-A: add tradeTimeout to now
- int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L);
+ long now = NTP.getTime();
+ int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L);
- TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A,
- receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount,
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME,
+ State.ALICE_WAITING_FOR_P2SH_A.name(), State.ALICE_WAITING_FOR_P2SH_A.value,
+ receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secretA, hashOfSecretA,
+ SupportedBlockchain.BITCOIN.name(),
tradeForeignPublicKey, tradeForeignPublicKeyHash,
- crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash);
+ crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
// Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin
- String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash);
+ String tradeForeignAddress = Bitcoin.getInstance().pkhToAddress(tradeForeignPublicKeyHash);
- long estimatedFee;
+ long p2shFee;
try {
- estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L);
- } catch (BitcoinException e) {
+ p2shFee = Bitcoin.getInstance().getP2shFee(now);
+ } catch (ForeignBlockchainException e) {
LOGGER.debug("Couldn't estimate Bitcoin fees?");
- return ResponseResult.BTC_NETWORK_ISSUE;
+ return ResponseResult.NETWORK_ISSUE;
}
// Fee for redeem/refund is subtracted from P2SH-A balance.
- long fundsRequiredForP2shA = estimatedFee /*funding P2SH-A*/ + crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-A*/;
- long fundsRequiredForP2shB = estimatedFee /*funding P2SH-B*/ + P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-B*/;
+ long fundsRequiredForP2shA = p2shFee /*funding P2SH-A*/ + crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/;
+ long fundsRequiredForP2shB = p2shFee /*funding P2SH-B*/ + P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/;
long totalFundsRequired = fundsRequiredForP2shA + fundsRequiredForP2shB;
// As buildSpend also adds a fee, this is more pessimistic than required
- Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired);
+ Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired);
if (fundingCheckTransaction == null)
- return ResponseResult.INSUFFICIENT_FUNDS;
+ return ResponseResult.BALANCE_ISSUE;
// P2SH-A to be funded
- byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA);
- String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
+ byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA);
+ String p2shAddress = Bitcoin.getInstance().deriveP2shAddress(redeemScriptBytes);
// Fund P2SH-A
// Do not include fee for funding transaction as this is covered by buildSpend()
- long amountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-A*/;
+ long amountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/;
- Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, amountA);
+ Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
if (p2shFundingTransaction == null) {
LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
- return ResponseResult.BTC_BALANCE_ISSUE;
+ return ResponseResult.BALANCE_ISSUE;
}
try {
- BTC.getInstance().broadcastTransaction(p2shFundingTransaction);
- } catch (BitcoinException e) {
+ Bitcoin.getInstance().broadcastTransaction(p2shFundingTransaction);
+ } catch (ForeignBlockchainException e) {
// We couldn't fund P2SH-A at this time
LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
- return ResponseResult.BTC_NETWORK_ISSUE;
+ return ResponseResult.NETWORK_ISSUE;
}
- updateTradeBotState(repository, tradeBotData, tradeBotData.getState(),
- () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress));
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress));
return ResponseResult.OK;
}
- private static byte[] generateTradePrivateKey() {
- // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both.
- // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that.
- return new ECKey().getPrivKeyBytes();
- }
+ @Override
+ public boolean canDelete(Repository repository, TradeBotData tradeBotData) {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null)
+ return true;
- private static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
- return PrivateKeyAccount.toPublicKey(privateKey);
- }
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ case ALICE_DONE:
+ case BOB_DONE:
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ return true;
- private static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
- return ECKey.fromPrivate(privateKey).getPubKey();
- }
-
- private static byte[] generateSecret() {
- byte[] secret = new byte[32];
- RANDOM.nextBytes(secret);
- return secret;
+ default:
+ return false;
+ }
}
@Override
- public void listen(Event event) {
- if (!(event instanceof Controller.NewBlockEvent))
+ public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null) {
+ LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress()));
return;
+ }
- synchronized (this) {
- // Get repo for trade situations
- try (final Repository repository = RepositoryManager.getRepository()) {
- List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
+ ATData atData = null;
+ CrossChainTradeData tradeData = null;
- for (TradeBotData tradeBotData : allTradeBotData) {
- repository.discardChanges();
-
- try {
- switch (tradeBotData.getState()) {
- case BOB_WAITING_FOR_AT_CONFIRM:
- handleBobWaitingForAtConfirm(repository, tradeBotData);
- break;
-
- case ALICE_WAITING_FOR_P2SH_A:
- handleAliceWaitingForP2shA(repository, tradeBotData);
- break;
-
- case BOB_WAITING_FOR_MESSAGE:
- handleBobWaitingForMessage(repository, tradeBotData);
- break;
-
- case ALICE_WAITING_FOR_AT_LOCK:
- handleAliceWaitingForAtLock(repository, tradeBotData);
- break;
-
- case BOB_WAITING_FOR_P2SH_B:
- handleBobWaitingForP2shB(repository, tradeBotData);
- break;
-
- case ALICE_WATCH_P2SH_B:
- handleAliceWatchingP2shB(repository, tradeBotData);
- break;
-
- case BOB_WAITING_FOR_AT_REDEEM:
- handleBobWaitingForAtRedeem(repository, tradeBotData);
- break;
-
- case ALICE_DONE:
- case BOB_DONE:
- break;
-
- case ALICE_REFUNDING_B:
- handleAliceRefundingP2shB(repository, tradeBotData);
- break;
-
- case ALICE_REFUNDING_A:
- handleAliceRefundingP2shA(repository, tradeBotData);
- break;
-
- case ALICE_REFUNDED:
- case BOB_REFUNDED:
- break;
-
- default:
- LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name()));
- }
- } catch (BitcoinException e) {
- LOGGER.warn(() -> String.format("Bitcoin issue processing %s: %s", tradeBotData.getAtAddress(), e.getMessage()));
- }
- }
- } catch (DataException e) {
- LOGGER.error("Couldn't run trade bot due to repository issue", e);
+ if (tradeBotState.requiresAtData) {
+ // Attempt to fetch AT data
+ atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
}
+
+ if (tradeBotState.requiresTradeData) {
+ tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
+ if (tradeData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ }
+ }
+
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ handleBobWaitingForAtConfirm(repository, tradeBotData);
+ break;
+
+ case ALICE_WAITING_FOR_P2SH_A:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case BOB_WAITING_FOR_MESSAGE:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_WAITING_FOR_AT_LOCK:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case BOB_WAITING_FOR_P2SH_B:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_WATCH_P2SH_B:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceWatchingP2shB(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case BOB_WAITING_FOR_AT_REDEEM:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_DONE:
+ case BOB_DONE:
+ break;
+
+ case ALICE_REFUNDING_B:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_REFUNDING_A:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ break;
}
}
@@ -412,18 +458,19 @@ public class TradeBot implements Listener {
// We've waited ages for AT to be confirmed into a block but something has gone awry.
// After this long we assume transaction loss so give up with trade-bot entry too.
- tradeBotData.setState(TradeBotData.State.BOB_REFUNDED);
+ tradeBotData.setState(State.BOB_REFUNDED.name());
+ tradeBotData.setStateValue(State.BOB_REFUNDED.value);
tradeBotData.setTimestamp(NTP.getTime());
// We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
repository.saveChanges();
LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
- notifyStateChange(tradeBotData);
+ TradeBot.notifyStateChange(tradeBotData);
return;
}
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_MESSAGE,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE,
() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
}
@@ -442,32 +489,25 @@ public class TradeBot implements Listener {
* lockTime of P2SH-A - also used to derive P2SH-A address, but also for other use later in the trading process
*
* If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
- if (atData == null) {
- LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
return;
- }
- CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
- String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA);
+ Bitcoin bitcoin = Bitcoin.getInstance();
- // If AT has finished then maybe Bob cancelled his trade offer
- if (atData.getIsFinished()) {
- // No point sending MESSAGE - might as well wait for refund
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
- () -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA));
- return;
- }
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
// Fee for redeem/refund is subtracted from P2SH-A balance.
- long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT;
- BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA);
+ long feeTimestampA = calcP2shAFeeTimestamp(tradeBotData.getLockTimeA(), crossChainTradeData.tradeTimeout);
+ long p2shFeeA = bitcoin.getP2shFee(feeTimestampA);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- switch (p2shStatus) {
+ switch (htlcStatusA) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
return;
@@ -475,13 +515,13 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B,
() -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return;
case REFUND_IN_PROGRESS:
case REFUNDED:
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
() -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
return;
@@ -493,7 +533,7 @@ public class TradeBot implements Listener {
// P2SH-A funding confirmed
// Attempt to send MESSAGE to Bob's Qortal trade address
- byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
+ byte[] messageData = BitcoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress;
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
@@ -514,7 +554,7 @@ public class TradeBot implements Listener {
}
}
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_AT_LOCK,
() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us",
p2shAddressA, messageRecipient, tradeBotData.getAtAddress()));
}
@@ -536,23 +576,19 @@ public class TradeBot implements Listener {
*
* Trade-bot's next step is to wait for P2SH-B, which will allow Bob to reveal his secret-B,
* needed by Alice to progress her side of the trade.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- // Fetch AT so we can determine trade start timestamp
- ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
- if (atData == null) {
- LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
- return;
- }
-
+ private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
// If AT has finished then Bob likely cancelled his trade offer
if (atData.getIsFinished()) {
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
return;
}
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
String address = tradeBotData.getTradeNativeAddress();
List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null);
@@ -575,7 +611,7 @@ public class TradeBot implements Listener {
// We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A
byte[] messageData = messageTransactionData.getData();
- BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData);
+ BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData);
if (offerMessageData == null)
continue;
@@ -584,14 +620,16 @@ public class TradeBot implements Listener {
int lockTimeA = (int) offerMessageData.lockTimeA;
// Determine P2SH-A address and confirm funded
- byte[] redeemScriptA = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
- String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA);
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
- final long minimumAmountA = tradeBotData.getBitcoinAmount() - P2SH_B_OUTPUT_AMOUNT;
+ long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFeeA = bitcoin.getP2shFee(feeTimestampA);
+ final long minimumAmountA = tradeBotData.getForeignAmount() - P2SH_B_OUTPUT_AMOUNT + p2shFeeA;
- BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA);
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- switch (p2shStatus) {
+ switch (htlcStatusA) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
// There might be another MESSAGE from someone else with an actually funded P2SH-A...
@@ -600,7 +638,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// This shouldn't occur, but defensively bump to next state
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B,
() -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return;
@@ -617,10 +655,10 @@ public class TradeBot implements Listener {
// Good to go - send MESSAGE to AT
String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
- int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
+ int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
// Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume
- byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
+ byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
String messageRecipient = tradeBotData.getAtAddress();
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData);
@@ -641,10 +679,10 @@ public class TradeBot implements Listener {
}
}
- byte[] redeemScriptB = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret());
- String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB);
+ byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret());
+ String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB);
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B,
() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB));
return;
@@ -652,7 +690,7 @@ public class TradeBot implements Listener {
// Don't resave/notify if we don't need to
if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature)
- updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), null);
+ TradeBot.updateTradeBotState(repository, tradeBotData, null);
}
/**
@@ -668,42 +706,44 @@ public class TradeBot implements Listener {
*
* If P2SH-B funding transaction is successfully broadcast to the Bitcoin network, trade-bot's next
* step is to watch for Bob revealing secret-B by redeeming P2SH-B.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
- if (atData == null) {
- LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
return;
- }
- CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A
- if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) {
- byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
- String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA);
+ Bitcoin bitcoin = Bitcoin.getInstance();
+ int lockTimeA = tradeBotData.getLockTimeA();
- long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT;
- BTCP2SH.Status p2shStatusA = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA);
+ // Refund P2SH-A if we've passed lockTime-A
+ if (NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) {
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
- switch (p2shStatusA) {
+ long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFeeA = bitcoin.getP2shFee(feeTimestampA);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
// This shouldn't occur, but defensively revert back to waiting for P2SH-A
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_P2SH_A,
() -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA));
return;
case REDEEM_IN_PROGRESS:
case REDEEMED:
// This shouldn't occur, but defensively bump to next state
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B,
() -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return;
case REFUND_IN_PROGRESS:
case REFUNDED:
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
() -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
return;
@@ -712,7 +752,7 @@ public class TradeBot implements Listener {
break;
}
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> atData.getIsFinished()
? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)
: String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
@@ -721,25 +761,10 @@ public class TradeBot implements Listener {
}
// We're waiting for AT to be in TRADE mode
- if (crossChainTradeData.mode != BTCACCT.Mode.TRADING)
+ if (crossChainTradeData.mode != AcctMode.TRADING)
return;
- // We're expecting AT to be locked to our native trade address
- if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) {
- // AT locked to different address! We shouldn't continue but wait and refund.
-
- byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
- String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes);
-
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
- () -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade",
- tradeBotData.getAtAddress(),
- crossChainTradeData.qortalPartnerAddress,
- tradeBotData.getTradeNativeAddress(),
- p2shAddress));
-
- return;
- }
+ // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
// Alice needs to fund P2SH-B here
@@ -752,8 +777,7 @@ public class TradeBot implements Listener {
}
long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp();
- int lockTimeA = tradeBotData.getLockTimeA();
- int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA);
+ int lockTimeB = BitcoinACCTv1.calcLockTimeB(recipientMessageTimestamp, lockTimeA);
// Our calculated lockTime-B should match AT's calculated lockTime-B
if (lockTimeB != crossChainTradeData.lockTimeB) {
@@ -762,18 +786,32 @@ public class TradeBot implements Listener {
return;
}
- byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
- String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB);
+ byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB);
- long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L);
+ long feeTimestampB = calcP2shBFeeTimestamp(lockTimeA, lockTimeB);
+ long p2shFeeB = bitcoin.getP2shFee(feeTimestampB);
// Have we funded P2SH-B already?
- final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee;
+ final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB;
- BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB);
+ BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB);
+
+ switch (htlcStatusB) {
+ case UNFUNDED: {
+ // Do not include fee for funding transaction as this is covered by buildSpend()
+ long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB /*redeeming/refunding P2SH-B*/;
+
+ Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getForeignKey(), p2shAddressB, amountB);
+ if (p2shFundingTransaction == null) {
+ LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?");
+ return;
+ }
+
+ bitcoin.broadcastTransaction(p2shFundingTransaction);
+ break;
+ }
- switch (p2shStatusB) {
- case UNFUNDED:
case FUNDING_IN_PROGRESS:
case FUNDED:
break;
@@ -781,32 +819,19 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// This shouldn't occur, but defensively bump to next state
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B,
() -> String.format("P2SH-B %s already spent? Defensively checking P2SH-B next", p2shAddressB));
return;
case REFUND_IN_PROGRESS:
case REFUNDED:
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB));
return;
}
- if (p2shStatusB == BTCP2SH.Status.UNFUNDED) {
- // Do not include fee for funding transaction as this is covered by buildSpend()
- long amountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-B*/;
-
- Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddressB, amountB);
- if (p2shFundingTransaction == null) {
- LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?");
- return;
- }
-
- BTC.getInstance().broadcastTransaction(p2shFundingTransaction);
- }
-
// P2SH-B funded, now we wait for Bob to redeem it
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B,
() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B",
tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB));
}
@@ -820,19 +845,13 @@ public class TradeBot implements Listener {
* Assuming P2SH-B is funded, trade-bot 'redeems' this P2SH using secret-B, thus revealing it to Alice.
*
* Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- 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);
-
+ private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
// If we've passed AT refund timestamp then AT will have finished after auto-refunding
if (atData.getIsFinished()) {
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
return;
@@ -843,17 +862,19 @@ public class TradeBot implements Listener {
// AT yet to process MESSAGE
return;
- byte[] redeemScriptB = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
- String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB);
+ Bitcoin bitcoin = Bitcoin.getInstance();
- int lockTimeA = crossChainTradeData.lockTimeA;
- long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L);
+ byte[] redeemScriptB = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB);
- final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee;
+ long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB);
+ long p2shFeeB = bitcoin.getP2shFee(feeTimestampB);
- BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB);
+ final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB;
- switch (p2shStatusB) {
+ BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB);
+
+ switch (htlcStatusB) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
// Still waiting for P2SH-B to be funded...
@@ -862,7 +883,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// This shouldn't occur, but defensively bump to next state
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
() -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress()));
return;
@@ -878,15 +899,16 @@ public class TradeBot implements Listener {
// Redeem P2SH-B using secret-B
Coin redeemAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. The real funds are in P2SH-A.
ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
- List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressB);
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB);
byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
- Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo);
+ Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey,
+ fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo);
- BTC.getInstance().broadcastTransaction(p2shRedeemTransaction);
+ bitcoin.broadcastTransaction(p2shRedeemTransaction);
// P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress()));
}
@@ -905,35 +927,25 @@ public class TradeBot implements Listener {
* In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A.
*
* If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
- if (atData == null) {
- LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
return;
- }
- CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData);
- // We check variable in AT that is set when Bob is refunded
- if (atData.getIsFinished() && crossChainTradeData.mode == BTCACCT.Mode.REFUNDED) {
- // Bob bailed out of trade so we must start refunding too
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_B,
- () -> String.format("AT %s has auto-refunded, Attempting refund also", tradeBotData.getAtAddress()));
+ Bitcoin bitcoin = Bitcoin.getInstance();
- return;
- }
+ byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB);
- byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
- String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB);
+ long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB);
+ long p2shFeeB = bitcoin.getP2shFee(feeTimestampB);
+ final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB;
- int lockTimeA = crossChainTradeData.lockTimeA;
- long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L);
- final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee;
+ BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB);
- BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB);
-
- switch (p2shStatusB) {
+ switch (htlcStatusB) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
case FUNDED:
@@ -948,14 +960,12 @@ public class TradeBot implements Listener {
case REFUND_IN_PROGRESS:
case REFUNDED:
// We've refunded P2SH-B? Bump to refunding P2SH-A then
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB));
return;
}
- List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddressB);
-
- byte[] secretB = BTCP2SH.findP2shSecret(p2shAddressB, p2shTransactions);
+ byte[] secretB = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddressB);
if (secretB == null)
// Secret not revealed at this time
return;
@@ -963,7 +973,7 @@ public class TradeBot implements Listener {
// Send 'redeem' MESSAGE to AT using both secrets
byte[] secretA = tradeBotData.getSecret();
String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH
- byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress);
+ byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, qortalReceivingAddress);
String messageRecipient = tradeBotData.getAtAddress();
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
@@ -984,7 +994,7 @@ public class TradeBot implements Listener {
}
}
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s",
p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress));
}
@@ -1001,38 +1011,25 @@ public class TradeBot implements Listener {
* (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output).
*
* If trade-bot successfully broadcasts the transaction, then this specific trade is done.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- 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);
-
+ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
// AT should be 'finished' once Alice has redeemed QORT funds
if (!atData.getIsFinished())
// Not finished yet
return;
- // If AT's balance should be zero
- AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT);
- if (atBalanceData != null && atBalanceData.getBalance() > 0L) {
- LOGGER.debug(() -> String.format("AT %s should have zero balance, not %s", tradeBotData.getAtAddress(), Amounts.prettyAmount(atBalanceData.getBalance())));
- return;
- }
-
- // We check variable in AT that is set when trade successfully completes
- if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) {
- // Not redeemed so must be refunded
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED,
+ // If AT is not REDEEMED then something has gone wrong
+ if (crossChainTradeData.mode != AcctMode.REDEEMED) {
+ // Not redeemed so must be refunded/cancelled
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
return;
}
- byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData);
+ byte[] secretA = BitcoinACCTv1.findSecretA(repository, crossChainTradeData);
if (secretA == null) {
LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
return;
@@ -1040,15 +1037,20 @@ public class TradeBot implements Listener {
// Use secret-A to redeem P2SH-A
+ Bitcoin bitcoin = Bitcoin.getInstance();
+ int lockTimeA = crossChainTradeData.lockTimeA;
+
byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
- byte[] redeemScriptA = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA);
- String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA);
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
// Fee for redeem/refund is subtracted from P2SH-A balance.
- long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT;
- BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA);
+ long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFeeA = bitcoin.getP2shFee(feeTimestampA);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- switch (p2shStatus) {
+ switch (htlcStatusA) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
// P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
@@ -1064,24 +1066,22 @@ public class TradeBot implements Listener {
// Wait for AT to auto-refund
return;
- case FUNDED:
- // Fall-through out of switch...
+ case FUNDED: {
+ Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT);
+ ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
+
+ Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey,
+ fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
+
+ bitcoin.broadcastTransaction(p2shRedeemTransaction);
break;
+ }
}
- if (p2shStatus == BTCP2SH.Status.FUNDED) {
- Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT);
- ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
- List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressA);
+ String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo);
- Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
-
- BTC.getInstance().broadcastTransaction(p2shRedeemTransaction);
- }
-
- String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo);
-
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_DONE,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
}
@@ -1091,36 +1091,38 @@ public class TradeBot implements Listener {
* We could potentially skip this step as P2SH-B is only funded with a token amount to cover the mining fee should Bob redeem P2SH-B.
*
* Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- 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);
+ private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ int lockTimeB = crossChainTradeData.lockTimeB;
// We can't refund P2SH-B until lockTime-B has passed
- if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L)
+ if (NTP.getTime() <= lockTimeB * 1000L)
return;
- // We can't refund P2SH-B until we've passed median block time
- int medianBlockTime = BTC.getInstance().getMedianBlockTime();
- if (NTP.getTime() <= medianBlockTime * 1000L)
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ // We can't refund P2SH-B until median block time has passed lockTime-B (see BIP113)
+ int medianBlockTime = bitcoin.getMedianBlockTime();
+ if (medianBlockTime <= lockTimeB)
return;
- byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB);
- String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB);
+ byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB);
+ String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB);
- int lockTimeA = crossChainTradeData.lockTimeA;
- long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L);
- final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee;
+ long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, lockTimeB);
+ long p2shFeeB = bitcoin.getP2shFee(feeTimestampB);
+ final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB;
- BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB);
+ BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB);
- switch (p2shStatusB) {
+ switch (htlcStatusB) {
case UNFUNDED:
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> String.format("P2SH-B %s never funded?. Refunding P2SH-A next", p2shAddressB));
+ return;
+
case FUNDING_IN_PROGRESS:
// Still waiting for P2SH-B to be funded...
return;
@@ -1128,7 +1130,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// We must be very close to trade timeout. Defensively try to refund P2SH-A
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB));
return;
@@ -1136,57 +1138,56 @@ public class TradeBot implements Listener {
case REFUNDED:
break;
- case FUNDED:
+ case FUNDED:{
+ Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees.
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB);
+
+ // Determine receive address for refund
+ String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
+ Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress);
+
+ Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptB, lockTimeB, receiving.getHash());
+
+ bitcoin.broadcastTransaction(p2shRefundTransaction);
break;
+ }
}
- if (p2shStatusB == BTCP2SH.Status.FUNDED) {
- Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees.
- ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
- List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressB);
-
- // Determine receive address for refund
- String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58());
- Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress);
-
- Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash());
-
- BTC.getInstance().broadcastTransaction(p2shRefundTransaction);
- }
-
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB));
}
/**
* Trade-bot is attempting to refund P2SH-A.
- * @throws BitcoinException
+ * @throws ForeignBlockchainException
*/
- private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException {
- 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);
+ private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ int lockTimeA = tradeBotData.getLockTimeA();
// We can't refund P2SH-A until lockTime-A has passed
- if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L)
+ if (NTP.getTime() <= lockTimeA * 1000L)
return;
- // We can't refund P2SH-A until we've passed median block time
- int medianBlockTime = BTC.getInstance().getMedianBlockTime();
- if (NTP.getTime() <= medianBlockTime * 1000L)
+ Bitcoin bitcoin = Bitcoin.getInstance();
+
+ // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
+ int medianBlockTime = bitcoin.getMedianBlockTime();
+ if (medianBlockTime <= lockTimeA)
return;
- byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
- String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA);
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
// Fee for redeem/refund is subtracted from P2SH-A balance.
- long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT;
- BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA);
+ long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFeeA = bitcoin.getP2shFee(feeTimestampA);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
- switch (p2shStatus) {
+ switch (htlcStatusA) {
case UNFUNDED:
case FUNDING_IN_PROGRESS:
// Still waiting for P2SH-A to be funded...
@@ -1195,7 +1196,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS:
case REDEEMED:
// Too late!
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
() -> String.format("P2SH-A %s already spent!", p2shAddressA));
return;
@@ -1203,50 +1204,66 @@ public class TradeBot implements Listener {
case REFUNDED:
break;
- case FUNDED:
- // Fall-through out of switch...
+ case FUNDED:{
+ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT);
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
+
+ // Determine receive address for refund
+ String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
+ Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress);
+
+ Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
+
+ bitcoin.broadcastTransaction(p2shRefundTransaction);
break;
+ }
}
- if (p2shStatus == BTCP2SH.Status.FUNDED) {
- Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT);
- ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
- List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressA);
-
- // Determine receive address for refund
- String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58());
- Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress);
-
- Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash());
-
- BTC.getInstance().broadcastTransaction(p2shRefundTransaction);
- }
-
- updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED,
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA));
}
- /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
- private static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, TradeBotData.State newState, Supplier logMessageSupplier) throws DataException {
- tradeBotData.setState(newState);
- tradeBotData.setTimestamp(NTP.getTime());
- repository.getCrossChainRepository().save(tradeBotData);
- repository.saveChanges();
+ /**
+ * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
+ *
+ * Will automatically update trade-bot state to ALICE_REFUNDING_B or ALICE_DONE as necessary.
+ *
+ * @throws DataException
+ * @throws ForeignBlockchainException
+ */
+ private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // This is OK
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING)
+ return false;
- if (Settings.getInstance().isTradebotSystrayEnabled())
- SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState.name()), MessageType.INFO);
+ boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
- if (logMessageSupplier != null)
- LOGGER.info(logMessageSupplier);
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING && isAtLockedToUs)
+ return false;
- LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState.name()));
+ if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) {
+ // We've redeemed already?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress()));
+ } else {
+ // Any other state is not good, so start defensive refund
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_B,
+ () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress()));
+ }
- notifyStateChange(tradeBotData);
+ return true;
}
- private static void notifyStateChange(TradeBotData tradeBotData) {
- StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
- EventBus.INSTANCE.notify(stateChangeEvent);
+ private long calcP2shAFeeTimestamp(int lockTimeA, int tradeTimeout) {
+ return (lockTimeA - tradeTimeout * 60) * 1000L;
+ }
+
+ private long calcP2shBFeeTimestamp(int lockTimeA, int lockTimeB) {
+ // lockTimeB is halfway between offerMessageTimestamp and lockTimeA
+ return (lockTimeA - (lockTimeA - lockTimeB) * 2) * 1000L;
}
}
diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java
new file mode 100644
index 00000000..0da3f0ce
--- /dev/null
+++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java
@@ -0,0 +1,884 @@
+package org.qortal.controller.tradebot;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.bitcoinj.core.Address;
+import org.bitcoinj.core.AddressFormatException;
+import org.bitcoinj.core.Coin;
+import org.bitcoinj.core.ECKey;
+import org.bitcoinj.core.Transaction;
+import org.bitcoinj.core.TransactionOutput;
+import org.bitcoinj.script.Script.ScriptType;
+import org.qortal.account.PrivateKeyAccount;
+import org.qortal.account.PublicKeyAccount;
+import org.qortal.api.model.crosschain.TradeBotCreateRequest;
+import org.qortal.asset.Asset;
+import org.qortal.crosschain.ACCT;
+import org.qortal.crosschain.AcctMode;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.Litecoin;
+import org.qortal.crosschain.LitecoinACCTv1;
+import org.qortal.crosschain.SupportedBlockchain;
+import org.qortal.crosschain.BitcoinyHTLC;
+import org.qortal.crypto.Crypto;
+import org.qortal.data.at.ATData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.data.transaction.BaseTransactionData;
+import org.qortal.data.transaction.DeployAtTransactionData;
+import org.qortal.data.transaction.MessageTransactionData;
+import org.qortal.group.Group;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.transaction.DeployAtTransaction;
+import org.qortal.transaction.MessageTransaction;
+import org.qortal.transaction.Transaction.ValidationResult;
+import org.qortal.transform.TransformationException;
+import org.qortal.transform.transaction.DeployAtTransactionTransformer;
+import org.qortal.utils.Base58;
+import org.qortal.utils.NTP;
+
+/**
+ * Performing cross-chain trading steps on behalf of user.
+ *
+ * We deal with three different independent state-spaces here:
+ *
+ * - Qortal blockchain
+ * - Foreign blockchain
+ * - Trade-bot entries
+ *
+ */
+public class LitecoinACCTv1TradeBot implements AcctTradeBot {
+
+ private static final Logger LOGGER = LogManager.getLogger(LitecoinACCTv1TradeBot.class);
+
+ public enum State implements TradeBot.StateNameAndValueSupplier {
+ BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
+ BOB_WAITING_FOR_MESSAGE(15, true, true),
+ BOB_WAITING_FOR_AT_REDEEM(25, true, true),
+ BOB_DONE(30, false, false),
+ BOB_REFUNDED(35, false, false),
+
+ ALICE_WAITING_FOR_AT_LOCK(85, true, true),
+ ALICE_DONE(95, false, false),
+ ALICE_REFUNDING_A(105, true, true),
+ ALICE_REFUNDED(110, false, false);
+
+ private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
+
+ public final int value;
+ public final boolean requiresAtData;
+ public final boolean requiresTradeData;
+
+ State(int value, boolean requiresAtData, boolean requiresTradeData) {
+ this.value = value;
+ this.requiresAtData = requiresAtData;
+ this.requiresTradeData = requiresTradeData;
+ }
+
+ public static State valueOf(int value) {
+ return map.get(value);
+ }
+
+ @Override
+ public String getState() {
+ return this.name();
+ }
+
+ @Override
+ public int getStateValue() {
+ return this.value;
+ }
+ }
+
+ /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
+ private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms
+
+ private static LitecoinACCTv1TradeBot instance;
+
+ private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream()
+ .map(State::name)
+ .collect(Collectors.toUnmodifiableList());
+
+ private LitecoinACCTv1TradeBot() {
+ }
+
+ public static synchronized LitecoinACCTv1TradeBot getInstance() {
+ if (instance == null)
+ instance = new LitecoinACCTv1TradeBot();
+
+ return instance;
+ }
+
+ @Override
+ public List getEndStates() {
+ return this.endStates;
+ }
+
+ /**
+ * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for LTC.
+ *
+ * Generates:
+ *
+ * - new 'trade' private key
+ *
+ * Derives:
+ *
+ * - 'native' (as in Qortal) public key, public key hash, address (starting with Q)
+ * - 'foreign' (as in Litecoin) public key, public key hash
+ *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment':
+ *
+ * - 'native'/Qortal 'trade' address - used as a MESSAGE contact
+ * - 'foreign'/Litecoin public key hash - used by Alice's P2SH scripts to allow redeem
+ * - QORT amount on offer by Bob
+ * - LTC amount expected in return by Bob (from Alice)
+ * - trading timeout, in case things go wrong and everyone needs to refund
+ *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
+ *
+ * Trade-bot will wait for Bob's AT to be deployed before taking next step.
+ *
+ * @param repository
+ * @param tradeBotCreateRequest
+ * @return raw, unsigned DEPLOY_AT transaction
+ * @throws DataException
+ */
+ public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
+
+ // Convert Litecoin receiving address into public key hash (we only support P2PKH at this time)
+ Address litecoinReceivingAddress;
+ try {
+ litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
+ } catch (AddressFormatException e) {
+ throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
+ }
+ if (litecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH)
+ throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
+
+ byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash();
+
+ PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey);
+
+ // Deploy AT
+ long timestamp = NTP.getTime();
+ byte[] reference = creator.getLastReference();
+ long fee = 0L;
+ byte[] signature = null;
+ BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature);
+
+ String name = "QORT/LTC ACCT";
+ String description = "QORT/LTC cross-chain trade";
+ String aTType = "ACCT";
+ String tags = "ACCT QORT LTC";
+ byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount,
+ tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
+ long amount = tradeBotCreateRequest.fundingQortAmount;
+
+ DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT);
+
+ DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
+ fee = deployAtTransaction.calcRecommendedFee();
+ deployAtTransactionData.setFee(fee);
+
+ DeployAtTransaction.ensureATAddress(deployAtTransactionData);
+ String atAddress = deployAtTransactionData.getAtAddress();
+
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME,
+ State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value,
+ creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ null, null,
+ SupportedBlockchain.LITECOIN.name(),
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ tradeBotCreateRequest.foreignAmount, null, null, null, litecoinReceivingAccountInfo);
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
+
+ // Return to user for signing and broadcast as we don't have their Qortal private key
+ try {
+ return DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
+ } catch (TransformationException e) {
+ throw new DataException("Failed to transform DEPLOY_AT transaction?", e);
+ }
+ }
+
+ /**
+ * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching LTC to an existing offer.
+ *
+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData
+ * and access to a Litecoin wallet via xprv58.
+ *
+ * The crossChainTradeData contains the current trade offer state
+ * as extracted from the AT's data segment.
+ *
+ * Access to a funded wallet is via a Litecoin BIP32 hierarchical deterministic key,
+ * passed via xprv58.
+ * This key will be stored in your node's database
+ * to allow trade-bot to create/fund the necessary P2SH transactions!
+ * However, due to the nature of BIP32 keys, it is possible to give the trade-bot
+ * only a subset of wallet access (see BIP32 for more details).
+ *
+ * As an example, the xprv58 can be extract from a legacy, password-less
+ * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Litecoin main-net)
+ * or 'tprv' for (Litecoin test-net).
+ *
+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet.
+ *
+ * If sufficient funds are available, this method will actually fund the P2SH-A
+ * with the Litecoin amount expected by 'Bob'.
+ *
+ * If the Litecoin transaction is successfully broadcast to the network then
+ * we also send a MESSAGE to Bob's trade-bot to let them know.
+ *
+ * The trade-bot entry is saved to the repository and the cross-chain trading process commences.
+ *
+ * @param repository
+ * @param crossChainTradeData chosen trade OFFER that Alice wants to match
+ * @param xprv58 funded wallet xprv in base58
+ * @return true if P2SH-A funding transaction successfully broadcast to Litecoin network, false otherwise
+ * @throws DataException
+ */
+ public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
+ byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
+ byte[] secretA = TradeBot.generateSecret();
+ byte[] hashOfSecretA = Crypto.hash160(secretA);
+
+ byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
+ byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
+ String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
+
+ byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
+ byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
+ byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH
+
+ // We need to generate lockTime-A: add tradeTimeout to now
+ long now = NTP.getTime();
+ int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L);
+
+ TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME,
+ State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value,
+ receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount,
+ tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
+ secretA, hashOfSecretA,
+ SupportedBlockchain.LITECOIN.name(),
+ tradeForeignPublicKey, tradeForeignPublicKeyHash,
+ crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
+
+ // Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount
+ long p2shFee;
+ try {
+ p2shFee = Litecoin.getInstance().getP2shFee(now);
+ } catch (ForeignBlockchainException e) {
+ LOGGER.debug("Couldn't estimate Litecoin fees?");
+ return ResponseResult.NETWORK_ISSUE;
+ }
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ // Do not include fee for funding transaction as this is covered by buildSpend()
+ long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/;
+
+ // P2SH-A to be funded
+ byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA);
+ String p2shAddress = Litecoin.getInstance().deriveP2shAddress(redeemScriptBytes);
+
+ // Build transaction for funding P2SH-A
+ Transaction p2shFundingTransaction = Litecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
+ if (p2shFundingTransaction == null) {
+ LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
+ return ResponseResult.BALANCE_ISSUE;
+ }
+
+ try {
+ Litecoin.getInstance().broadcastTransaction(p2shFundingTransaction);
+ } catch (ForeignBlockchainException e) {
+ // We couldn't fund P2SH-A at this time
+ LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
+ return ResponseResult.NETWORK_ISSUE;
+ }
+
+ // Attempt to send MESSAGE to Bob's Qortal trade address
+ byte[] messageData = LitecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
+ String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress;
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
+
+ messageTransaction.computeNonce();
+ messageTransaction.sign(sender);
+
+ // reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = messageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name()));
+ return ResponseResult.NETWORK_ISSUE;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress));
+
+ return ResponseResult.OK;
+ }
+
+ @Override
+ public boolean canDelete(Repository repository, TradeBotData tradeBotData) {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null)
+ return true;
+
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ case ALICE_DONE:
+ case BOB_DONE:
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ return true;
+
+ default:
+ return false;
+ }
+ }
+
+ @Override
+ public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
+ State tradeBotState = State.valueOf(tradeBotData.getStateValue());
+ if (tradeBotState == null) {
+ LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ ATData atData = null;
+ CrossChainTradeData tradeData = null;
+
+ if (tradeBotState.requiresAtData) {
+ // Attempt to fetch AT data
+ atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
+ if (atData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ if (tradeBotState.requiresTradeData) {
+ tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
+ if (tradeData == null) {
+ LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress()));
+ return;
+ }
+ }
+ }
+
+ switch (tradeBotState) {
+ case BOB_WAITING_FOR_AT_CONFIRM:
+ handleBobWaitingForAtConfirm(repository, tradeBotData);
+ break;
+
+ case BOB_WAITING_FOR_MESSAGE:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_WAITING_FOR_AT_LOCK:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case BOB_WAITING_FOR_AT_REDEEM:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_DONE:
+ case BOB_DONE:
+ break;
+
+ case ALICE_REFUNDING_A:
+ TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
+ handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
+ break;
+
+ case ALICE_REFUNDED:
+ case BOB_REFUNDED:
+ break;
+ }
+ }
+
+ /**
+ * Trade-bot is waiting for Bob's AT to deploy.
+ *
+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice.
+ */
+ private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException {
+ if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) {
+ if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD)
+ return;
+
+ // We've waited ages for AT to be confirmed into a block but something has gone awry.
+ // After this long we assume transaction loss so give up with trade-bot entry too.
+ tradeBotData.setState(State.BOB_REFUNDED.name());
+ tradeBotData.setStateValue(State.BOB_REFUNDED.value);
+ tradeBotData.setTimestamp(NTP.getTime());
+ // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
+ repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
+ repository.saveChanges();
+
+ LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
+ TradeBot.notifyStateChange(tradeBotData);
+ return;
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE,
+ () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
+ }
+
+ /**
+ * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info.
+ *
+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund,
+ * in which case trade-bot is done with this specific trade and finalizes on refunded state.
+ *
+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot.
+ *
+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance.
+ *
+ * Assuming P2SH-A has at least expected Litecoin balance,
+ * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details.
+ *
+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice.
+ *
+ * Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to
+ * extract secret-A needed to redeem Alice's P2SH.
+ * @throws ForeignBlockchainException
+ */
+ private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // If AT has finished then Bob likely cancelled his trade offer
+ if (atData.getIsFinished()) {
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
+ () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ Litecoin litecoin = Litecoin.getInstance();
+
+ String address = tradeBotData.getTradeNativeAddress();
+ List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null);
+
+ for (MessageTransactionData messageTransactionData : messageTransactionsData) {
+ if (messageTransactionData.isText())
+ continue;
+
+ // We're expecting: HASH160(secret-A), Alice's Litecoin pubkeyhash and lockTime-A
+ byte[] messageData = messageTransactionData.getData();
+ LitecoinACCTv1.OfferMessageData offerMessageData = LitecoinACCTv1.extractOfferMessageData(messageData);
+ if (offerMessageData == null)
+ continue;
+
+ byte[] aliceForeignPublicKeyHash = offerMessageData.partnerLitecoinPKH;
+ byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
+ int lockTimeA = (int) offerMessageData.lockTimeA;
+ long messageTimestamp = messageTransactionData.getTimestamp();
+ int refundTimeout = LitecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA);
+
+ // Determine P2SH-A address and confirm funded
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
+ String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
+
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
+ final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee;
+
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // There might be another MESSAGE from someone else with an actually funded P2SH-A...
+ continue;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // We've already redeemed this?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
+ () -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A...
+ continue;
+
+ case FUNDED:
+ // Fall-through out of switch...
+ break;
+ }
+
+ // Good to go - send MESSAGE to AT
+
+ String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
+
+ // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume
+ byte[] outgoingMessageData = LitecoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
+ String messageRecipient = tradeBotData.getAtAddress();
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false);
+
+ outgoingMessageTransaction.computeNonce();
+ outgoingMessageTransaction.sign(sender);
+
+ // reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
+ return;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
+ () -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress));
+
+ return;
+ }
+ }
+
+ /**
+ * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only.
+ *
+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow
+ * this process has taken so long that we've reached P2SH-A's locktime, or that someone else
+ * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process.
+ *
+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct.
+ *
+ * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice.
+ *
+ * In revealing a valid secret-A, Bob can then redeem the LTC funds from P2SH-A.
+ *
+ * @throws ForeignBlockchainException
+ */
+ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
+ return;
+
+ Litecoin litecoin = Litecoin.getInstance();
+ int lockTimeA = tradeBotData.getLockTimeA();
+
+ // Refund P2SH-A if we've passed lockTime-A
+ if (NTP.getTime() >= lockTimeA * 1000L) {
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
+
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ case FUNDED:
+ break;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Already redeemed?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
+ () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
+ return;
+
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> atData.getIsFinished()
+ ? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)
+ : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
+
+ return;
+ }
+
+ // We're waiting for AT to be in TRADE mode
+ if (crossChainTradeData.mode != AcctMode.TRADING)
+ return;
+
+ // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
+
+ // Find our MESSAGE to AT from previous state
+ List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(),
+ crossChainTradeData.qortalCreatorTradeAddress, null, null, null);
+ if (messageTransactionsData == null || messageTransactionsData.isEmpty()) {
+ LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress));
+ return;
+ }
+
+ long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp();
+ int refundTimeout = LitecoinACCTv1.calcRefundTimeout(recipientMessageTimestamp, lockTimeA);
+
+ // Our calculated refundTimeout should match AT's refundTimeout
+ if (refundTimeout != crossChainTradeData.refundTimeout) {
+ LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout));
+ // We'll eventually refund
+ return;
+ }
+
+ // We're good to redeem AT
+
+ // Send 'redeem' MESSAGE to AT using both secret
+ byte[] secretA = tradeBotData.getSecret();
+ String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH
+ byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretA, qortalReceivingAddress);
+ String messageRecipient = tradeBotData.getAtAddress();
+
+ boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
+ if (!isMessageAlreadySent) {
+ PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false);
+
+ messageTransaction.computeNonce();
+ messageTransaction.sign(sender);
+
+ // Reset repository state to prevent deadlock
+ repository.discardChanges();
+ ValidationResult result = messageTransaction.importAsUnconfirmed();
+
+ if (result != ValidationResult.OK) {
+ LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name()));
+ return;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("Redeeming AT %s. Funds should arrive at %s",
+ tradeBotData.getAtAddress(), qortalReceivingAddress));
+ }
+
+ /**
+ * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the LTC funds from P2SH-A.
+ *
+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case,
+ * trade-bot is done with this specific trade and finalizes in refunded state.
+ *
+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the LTC funds from P2SH-A
+ * to Bob's 'foreign'/Litecoin trade legacy-format address, as derived from trade private key.
+ *
+ * (This could potentially be 'improved' to send LTC to any address of Bob's choosing by changing the transaction output).
+ *
+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done.
+ * @throws ForeignBlockchainException
+ */
+ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // AT should be 'finished' once Alice has redeemed QORT funds
+ if (!atData.getIsFinished())
+ // Not finished yet
+ return;
+
+ // If AT is not REDEEMED then something has gone wrong
+ if (crossChainTradeData.mode != AcctMode.REDEEMED) {
+ // Not redeemed so must be refunded/cancelled
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
+ () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
+
+ return;
+ }
+
+ byte[] secretA = LitecoinACCTv1.findSecretA(repository, crossChainTradeData);
+ if (secretA == null) {
+ LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress()));
+ return;
+ }
+
+ // Use secret-A to redeem P2SH-A
+
+ Litecoin litecoin = Litecoin.getInstance();
+
+ byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
+ int lockTimeA = crossChainTradeData.lockTimeA;
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
+ String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
+ return;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Double-check that we have redeemed P2SH-A...
+ break;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ // Wait for AT to auto-refund
+ return;
+
+ case FUNDED: {
+ Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
+ ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA);
+
+ Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey,
+ fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
+
+ litecoin.broadcastTransaction(p2shRedeemTransaction);
+ break;
+ }
+ }
+
+ String receivingAddress = litecoin.pkhToAddress(receivingAccountInfo);
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
+ () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
+ }
+
+ /**
+ * Trade-bot is attempting to refund P2SH-A.
+ * @throws ForeignBlockchainException
+ */
+ private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ int lockTimeA = tradeBotData.getLockTimeA();
+
+ // We can't refund P2SH-A until lockTime-A has passed
+ if (NTP.getTime() <= lockTimeA * 1000L)
+ return;
+
+ Litecoin litecoin = Litecoin.getInstance();
+
+ // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
+ int medianBlockTime = litecoin.getMedianBlockTime();
+ if (medianBlockTime <= lockTimeA)
+ return;
+
+ byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
+ String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
+
+ // Fee for redeem/refund is subtracted from P2SH-A balance.
+ long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
+ long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
+ long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
+ BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
+
+ switch (htlcStatusA) {
+ case UNFUNDED:
+ case FUNDING_IN_PROGRESS:
+ // Still waiting for P2SH-A to be funded...
+ return;
+
+ case REDEEM_IN_PROGRESS:
+ case REDEEMED:
+ // Too late!
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("P2SH-A %s already spent!", p2shAddressA));
+ return;
+
+ case REFUND_IN_PROGRESS:
+ case REFUNDED:
+ break;
+
+ case FUNDED:{
+ Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
+ ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
+ List fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA);
+
+ // Determine receive address for refund
+ String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
+ Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress);
+
+ Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
+
+ litecoin.broadcastTransaction(p2shRefundTransaction);
+ break;
+ }
+ }
+
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
+ () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA));
+ }
+
+ /**
+ * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
+ *
+ * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE as necessary.
+ *
+ * @throws DataException
+ * @throws ForeignBlockchainException
+ */
+ private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData,
+ ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
+ // This is OK
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING)
+ return false;
+
+ boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
+
+ if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING)
+ if (isAtLockedToUs) {
+ // AT is trading with us - OK
+ return false;
+ } else {
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress));
+
+ return true;
+ }
+
+ if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) {
+ // We've redeemed already?
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
+ () -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress()));
+ } else {
+ // Any other state is not good, so start defensive refund
+ TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
+ () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress()));
+ }
+
+ return true;
+ }
+
+ private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) {
+ return (lockTimeA - tradeTimeout * 60) * 1000L;
+ }
+
+}
diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java
new file mode 100644
index 00000000..84e32125
--- /dev/null
+++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java
@@ -0,0 +1,362 @@
+package org.qortal.controller.tradebot;
+
+import java.awt.TrayIcon.MessageType;
+import java.security.SecureRandom;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.logging.log4j.util.Supplier;
+import org.bitcoinj.core.ECKey;
+import org.qortal.account.PrivateKeyAccount;
+import org.qortal.api.model.crosschain.TradeBotCreateRequest;
+import org.qortal.controller.Controller;
+import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult;
+import org.qortal.crosschain.ACCT;
+import org.qortal.crosschain.BitcoinACCTv1;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.LitecoinACCTv1;
+import org.qortal.crosschain.SupportedBlockchain;
+import org.qortal.data.at.ATData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.data.transaction.BaseTransactionData;
+import org.qortal.data.transaction.PresenceTransactionData;
+import org.qortal.event.Event;
+import org.qortal.event.EventBus;
+import org.qortal.event.Listener;
+import org.qortal.group.Group;
+import org.qortal.gui.SysTray;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.repository.RepositoryManager;
+import org.qortal.settings.Settings;
+import org.qortal.transaction.PresenceTransaction;
+import org.qortal.transaction.PresenceTransaction.PresenceType;
+import org.qortal.transaction.Transaction.ValidationResult;
+import org.qortal.transform.transaction.TransactionTransformer;
+import org.qortal.utils.NTP;
+
+import com.google.common.primitives.Longs;
+
+/**
+ * Performing cross-chain trading steps on behalf of user.
+ *
+ * We deal with three different independent state-spaces here:
+ *
+ * - Qortal blockchain
+ * - Foreign blockchain
+ * - Trade-bot entries
+ *
+ */
+public class TradeBot implements Listener {
+
+ private static final Logger LOGGER = LogManager.getLogger(TradeBot.class);
+ private static final Random RANDOM = new SecureRandom();
+
+ public interface StateNameAndValueSupplier {
+ public String getState();
+ public int getStateValue();
+ }
+
+ public static class StateChangeEvent implements Event {
+ private final TradeBotData tradeBotData;
+
+ public StateChangeEvent(TradeBotData tradeBotData) {
+ this.tradeBotData = tradeBotData;
+ }
+
+ public TradeBotData getTradeBotData() {
+ return this.tradeBotData;
+ }
+ }
+
+ private static final Map, Supplier> acctTradeBotSuppliers = new HashMap<>();
+ static {
+ acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
+ acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
+ }
+
+ private static TradeBot instance;
+
+ private final Map presenceTimestampsByAtAddress = Collections.synchronizedMap(new HashMap<>());
+
+ private TradeBot() {
+ EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
+ }
+
+ public static synchronized TradeBot getInstance() {
+ if (instance == null)
+ instance = new TradeBot();
+
+ return instance;
+ }
+
+ public ACCT getAcctUsingAtData(ATData atData) {
+ byte[] codeHash = atData.getCodeHash();
+ if (codeHash == null)
+ return null;
+
+ return SupportedBlockchain.getAcctByCodeHash(codeHash);
+ }
+
+ public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
+ ACCT acct = this.getAcctUsingAtData(atData);
+ if (acct == null)
+ return null;
+
+ return acct.populateTradeData(repository, atData);
+ }
+
+ /**
+ * Creates a new trade-bot entry from the "Bob" viewpoint,
+ * i.e. OFFERing QORT in exchange for foreign blockchain currency.
+ *
+ * Generates:
+ *
+ * - new 'trade' private key
+ * - secret(s)
+ *
+ * Derives:
+ *
+ * - 'native' (as in Qortal) public key, public key hash, address (starting with Q)
+ * - 'foreign' public key, public key hash
+ * - hash(es) of secret(s)
+ *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment':
+ *
+ * - 'native' (Qortal) 'trade' address - used to MESSAGE AT
+ * - 'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain
+ * - hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed
+ * - QORT amount on offer by Bob
+ * - foreign currency amount expected in return by Bob (from Alice)
+ * - trading timeout, in case things go wrong and everyone needs to refund
+ *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
+ *
+ * Trade-bot will wait for Bob's AT to be deployed before taking next step.
+ *
+ * @param repository
+ * @param tradeBotCreateRequest
+ * @return raw, unsigned DEPLOY_AT transaction
+ * @throws DataException
+ */
+ public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
+ // Fetch latest ACCT version for requested foreign blockchain
+ ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct();
+
+ AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
+ if (acctTradeBot == null)
+ return null;
+
+ return acctTradeBot.createTrade(repository, tradeBotCreateRequest);
+ }
+
+ /**
+ * Creates a trade-bot entry from the 'Alice' viewpoint,
+ * i.e. matching foreign blockchain currency to an existing QORT offer.
+ *
+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData
+ * and access to a foreign blockchain wallet via foreignKey.
+ *
+ * @param repository
+ * @param crossChainTradeData chosen trade OFFER that Alice wants to match
+ * @param foreignKey foreign blockchain wallet key
+ * @throws DataException
+ */
+ public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
+ CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException {
+ AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
+ if (acctTradeBot == null) {
+ LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress()));
+ return ResponseResult.NETWORK_ISSUE;
+ }
+
+ // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT.
+ if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(atData.getATAddress(), acctTradeBot.getEndStates()))
+ return ResponseResult.TRADE_ALREADY_EXISTS;
+
+ return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress);
+ }
+
+ public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException {
+ TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey);
+ if (tradeBotData == null)
+ // Can't delete what we don't have!
+ return false;
+
+ boolean canDelete = false;
+
+ ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
+ if (acct == null)
+ // We can't/no longer support this ACCT
+ canDelete = true;
+ else {
+ AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
+ canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData);
+ }
+
+ if (canDelete) {
+ repository.getCrossChainRepository().delete(tradePrivateKey);
+ repository.saveChanges();
+ }
+
+ return canDelete;
+ }
+
+ @Override
+ public void listen(Event event) {
+ if (!(event instanceof Controller.NewBlockEvent))
+ return;
+
+ synchronized (this) {
+ List allTradeBotData;
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
+ } catch (DataException e) {
+ LOGGER.error("Couldn't run trade bot due to repository issue", e);
+ return;
+ }
+
+ for (TradeBotData tradeBotData : allTradeBotData)
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ // Find ACCT-specific trade-bot for this entry
+ ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
+ if (acct == null) {
+ LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName()));
+ continue;
+ }
+
+ AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
+ if (acctTradeBot == null) {
+ LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName()));
+ continue;
+ }
+
+ acctTradeBot.progress(repository, tradeBotData);
+ } catch (DataException e) {
+ LOGGER.error("Couldn't run trade bot due to repository issue", e);
+ } catch (ForeignBlockchainException e) {
+ LOGGER.warn(() -> String.format("Foreign blockchain issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage()));
+ }
+ }
+ }
+
+ /*package*/ static byte[] generateTradePrivateKey() {
+ // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both.
+ // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that.
+ return new ECKey().getPrivKeyBytes();
+ }
+
+ /*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
+ return PrivateKeyAccount.toPublicKey(privateKey);
+ }
+
+ /*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
+ return ECKey.fromPrivate(privateKey).getPubKey();
+ }
+
+ /*package*/ static byte[] generateSecret() {
+ byte[] secret = new byte[32];
+ RANDOM.nextBytes(secret);
+ return secret;
+ }
+
+ /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
+ /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData,
+ String newState, int newStateValue, Supplier logMessageSupplier) throws DataException {
+ tradeBotData.setState(newState);
+ tradeBotData.setStateValue(newStateValue);
+ tradeBotData.setTimestamp(NTP.getTime());
+ repository.getCrossChainRepository().save(tradeBotData);
+ repository.saveChanges();
+
+ if (Settings.getInstance().isTradebotSystrayEnabled())
+ SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO);
+
+ if (logMessageSupplier != null)
+ LOGGER.info(logMessageSupplier);
+
+ LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState));
+
+ notifyStateChange(tradeBotData);
+ }
+
+ /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
+ /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier logMessageSupplier) throws DataException {
+ updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier);
+ }
+
+ /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
+ /*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier logMessageSupplier) throws DataException {
+ updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier);
+ }
+
+ /*package*/ static void notifyStateChange(TradeBotData tradeBotData) {
+ StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
+ EventBus.INSTANCE.notify(stateChangeEvent);
+ }
+
+ /*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) {
+ Supplier acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass());
+ if (acctTradeBotSupplier == null)
+ return null;
+
+ return acctTradeBotSupplier.get();
+ }
+
+ // PRESENCE-related
+ /*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData, CrossChainTradeData tradeData)
+ throws DataException {
+ String atAddress = tradeBotData.getAtAddress();
+
+ PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
+ String signerAddress = tradeNativeAccount.getAddress();
+
+ /*
+ * There's no point in Alice trying to build a PRESENCE transaction
+ * for an AT that isn't locked to her, as other peers won't be able
+ * to validate the PRESENCE transaction as signing public key won't
+ * be visible.
+ */
+ if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress))
+ // Signer is neither Bob, nor Alice, or trade not yet locked to Alice
+ return;
+
+ long now = NTP.getTime();
+ long threshold = now - PresenceType.TRADE_BOT.getLifetime();
+
+ long timestamp = presenceTimestampsByAtAddress.compute(atAddress, (k, v) -> (v == null || v < threshold) ? now : v);
+
+ // If timestamp hasn't been updated then nothing to do
+ if (timestamp != now)
+ return;
+
+ int txGroupId = Group.NO_GROUP;
+ byte[] reference = new byte[TransactionTransformer.SIGNATURE_LENGTH];
+ byte[] creatorPublicKey = tradeNativeAccount.getPublicKey();
+ long fee = 0L;
+
+ BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null);
+
+ int nonce = 0;
+ byte[] timestampSignature = tradeNativeAccount.sign(Longs.toByteArray(timestamp));
+
+ PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature);
+
+ PresenceTransaction presenceTransaction = new PresenceTransaction(repository, transactionData);
+ presenceTransaction.computeNonce();
+
+ presenceTransaction.sign(tradeNativeAccount);
+
+ ValidationResult result = presenceTransaction.importAsUnconfirmed();
+ if (result != ValidationResult.OK)
+ LOGGER.debug(() -> String.format("Unable to build trade-bot PRESENCE transaction for %s: %s", tradeBotData.getAtAddress(), result.name()));
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/ACCT.java b/src/main/java/org/qortal/crosschain/ACCT.java
new file mode 100644
index 00000000..e557a3e2
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/ACCT.java
@@ -0,0 +1,23 @@
+package org.qortal.crosschain;
+
+import org.qortal.data.at.ATData;
+import org.qortal.data.at.ATStateData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+
+public interface ACCT {
+
+ public byte[] getCodeBytesHash();
+
+ public int getModeByteOffset();
+
+ public ForeignBlockchain getBlockchain();
+
+ public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException;
+
+ public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException;
+
+ public byte[] buildCancelMessage(String creatorQortalAddress);
+
+}
diff --git a/src/main/java/org/qortal/crosschain/AcctMode.java b/src/main/java/org/qortal/crosschain/AcctMode.java
new file mode 100644
index 00000000..21496032
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/AcctMode.java
@@ -0,0 +1,21 @@
+package org.qortal.crosschain;
+
+import static java.util.Arrays.stream;
+import static java.util.stream.Collectors.toMap;
+
+import java.util.Map;
+
+public enum AcctMode {
+ OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
+
+ public final int value;
+ private static final Map map = stream(AcctMode.values()).collect(toMap(mode -> mode.value, mode -> mode));
+
+ AcctMode(int value) {
+ this.value = value;
+ }
+
+ public static AcctMode valueOf(int value) {
+ return map.get(value);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java
deleted file mode 100644
index 06cfe000..00000000
--- a/src/main/java/org/qortal/crosschain/BTC.java
+++ /dev/null
@@ -1,559 +0,0 @@
-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.Context;
-import org.bitcoinj.core.ECKey;
-import org.bitcoinj.core.InsufficientMoneyException;
-import org.bitcoinj.core.LegacyAddress;
-import org.bitcoinj.core.NetworkParameters;
-import org.bitcoinj.core.Sha256Hash;
-import org.bitcoinj.core.Transaction;
-import org.bitcoinj.core.TransactionOutput;
-import org.bitcoinj.core.UTXO;
-import org.bitcoinj.core.UTXOProvider;
-import org.bitcoinj.core.UTXOProviderException;
-import org.bitcoinj.crypto.ChildNumber;
-import org.bitcoinj.crypto.DeterministicHierarchy;
-import org.bitcoinj.crypto.DeterministicKey;
-import org.bitcoinj.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.crypto.Crypto;
-import org.qortal.settings.Settings;
-import org.qortal.utils.BitTwiddling;
-
-import com.google.common.hash.HashCode;
-
-public class BTC {
-
- public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
- public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
- public static final int HASH160_LENGTH = 20;
-
- public static final boolean INCLUDE_UNCONFIRMED = true;
- public static final boolean EXCLUDE_UNCONFIRMED = false;
-
- protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
-
- // Temporary values until a dynamic fee system is written.
- private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
- private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
- private static final long NEW_FEE_AMOUNT = 10_000L;
- private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
-
- private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
- private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
-
- public enum BitcoinNet {
- MAIN {
- @Override
- public NetworkParameters getParams() {
- return MainNetParams.get();
- }
- },
- TEST3 {
- @Override
- public NetworkParameters getParams() {
- return TestNet3Params.get();
- }
- },
- REGTEST {
- @Override
- public NetworkParameters getParams() {
- return RegTestParams.get();
- }
- };
-
- public abstract NetworkParameters getParams();
- }
-
- private static BTC instance;
- private final NetworkParameters params;
- private final ElectrumX electrumX;
- private final Context bitcoinjContext;
-
- // Let ECKey.equals() do the hard work
- private final Set spentKeys = new HashSet<>();
-
- // Constructors and instance
-
- private BTC() {
- BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
- this.params = bitcoinNet.getParams();
-
- LOGGER.info(() -> String.format("Starting Bitcoin support using %s", bitcoinNet.name()));
-
- this.electrumX = ElectrumX.getInstance(bitcoinNet.name());
- this.bitcoinjContext = new Context(this.params);
- }
-
- public static synchronized BTC getInstance() {
- if (instance == null)
- instance = new BTC();
-
- return instance;
- }
-
- // Getters & setters
-
- public NetworkParameters getNetworkParameters() {
- return this.params;
- }
-
- public static synchronized void resetForTesting() {
- instance = null;
- }
-
- // Actual useful methods for use by other classes
-
- public static String format(Coin amount) {
- return BTC.FORMAT.format(amount).toString();
- }
-
- public static String format(long amount) {
- return format(Coin.valueOf(amount));
- }
-
- public boolean isValidXprv(String xprv58) {
- try {
- Context.propagate(bitcoinjContext);
- DeterministicKey.deserializeB58(null, xprv58, this.params);
- return true;
- } catch (IllegalArgumentException e) {
- return false;
- }
- }
-
- /** Returns P2PKH Bitcoin address using passed public key hash. */
- public String pkhToAddress(byte[] publicKeyHash) {
- Context.propagate(bitcoinjContext);
- return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
- }
-
- public String deriveP2shAddress(byte[] redeemScriptBytes) {
- byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
- Context.propagate(bitcoinjContext);
- Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
- return p2shAddress.toString();
- }
-
- /**
- * Returns median timestamp from latest 11 blocks, in seconds.
- *
- * @throws BitcoinException if error occurs
- */
- public Integer getMedianBlockTime() throws BitcoinException {
- int height = this.electrumX.getCurrentHeight();
-
- // Grab latest 11 blocks
- List blockHeaders = this.electrumX.getBlockHeaders(height - 11, 11);
- if (blockHeaders.size() < 11)
- throw new BitcoinException("Not enough blocks to determine median block time");
-
- List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
-
- // Descending order
- blockTimestamps.sort((a, b) -> Integer.compare(b, a));
-
- // Pick median
- return blockTimestamps.get(5);
- }
-
- /**
- * Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp.
- *
- * @param timestamp optional milliseconds since epoch, or null for 'now'
- * @return sats per 1000bytes, or throws BitcoinException if something went wrong
- */
- public long estimateFee(Long timestamp) throws BitcoinException {
- if (!this.params.getId().equals(NetworkParameters.ID_MAINNET))
- return NON_MAINNET_FEE;
-
- // TODO: This will need to be replaced with something better in the near future!
- if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP)
- return OLD_FEE_AMOUNT;
-
- return NEW_FEE_AMOUNT;
- }
-
- /**
- * Returns confirmed balance, based on passed payment script.
- *
- * @return confirmed balance, or zero if script unknown
- * @throws BitcoinException if there was an error
- */
- public long getConfirmedBalance(String base58Address) throws BitcoinException {
- return this.electrumX.getConfirmedBalance(addressToScript(base58Address));
- }
-
- /**
- * Returns list of unspent outputs pertaining to passed address.
- *
- * @return list of unspent outputs, or empty list if address unknown
- * @throws BitcoinException if there was an error.
- */
- public List getUnspentOutputs(String base58Address) throws BitcoinException {
- List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address), false);
-
- List unspentTransactionOutputs = new ArrayList<>();
- for (UnspentOutput unspentOutput : unspentOutputs) {
- List transactionOutputs = this.getOutputs(unspentOutput.hash);
-
- unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
- }
-
- return unspentTransactionOutputs;
- }
-
- /**
- * Returns list of outputs pertaining to passed transaction hash.
- *
- * @return list of outputs, or empty list if transaction unknown
- * @throws BitcoinException if there was an error.
- */
- public List getOutputs(byte[] txHash) throws BitcoinException {
- byte[] rawTransactionBytes = this.electrumX.getRawTransaction(txHash);
-
- // XXX bitcoinj: replace with getTransaction() below
- Context.propagate(bitcoinjContext);
- Transaction transaction = new Transaction(this.params, rawTransactionBytes);
- return transaction.getOutputs();
- }
-
- /**
- * Returns list of transaction hashes pertaining to passed address.
- *
- * @return list of unspent outputs, or empty list if script unknown
- * @throws BitcoinException if there was an error.
- */
- public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws BitcoinException {
- return this.electrumX.getAddressTransactions(addressToScript(base58Address), includeUnconfirmed);
- }
-
- /**
- * Returns list of raw, confirmed transactions involving given address.
- *
- * @throws BitcoinException if there was an error
- */
- public List getAddressTransactions(String base58Address) throws BitcoinException {
- List transactionHashes = this.electrumX.getAddressTransactions(addressToScript(base58Address), false);
-
- List rawTransactions = new ArrayList<>();
- for (TransactionHash transactionInfo : transactionHashes) {
- byte[] rawTransaction = this.electrumX.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes());
- rawTransactions.add(rawTransaction);
- }
-
- return rawTransactions;
- }
-
- /**
- * Returns transaction info for passed transaction hash.
- *
- * @throws BitcoinException.NotFoundException if transaction unknown
- * @throws BitcoinException if error occurs
- */
- public BitcoinTransaction getTransaction(String txHash) throws BitcoinException {
- return this.electrumX.getTransaction(txHash);
- }
-
- /**
- * Broadcasts raw transaction to Bitcoin network.
- *
- * @throws BitcoinException if error occurs
- */
- public void broadcastTransaction(Transaction transaction) throws BitcoinException {
- 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) {
- Context.propagate(bitcoinjContext);
- Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
- wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
-
- Address destination = Address.fromString(this.params, recipient);
- SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
-
- if (this.params == TestNet3Params.get())
- // Much smaller fee for TestNet3
- sendRequest.feePerKb = Coin.valueOf(2000L);
-
- try {
- wallet.completeTx(sendRequest);
- return sendRequest.tx;
- } catch (InsufficientMoneyException e) {
- return null;
- }
- }
-
- /**
- * Returns unspent Bitcoin balance given 'm' BIP32 key.
- *
- * @param xprv58 BIP32 extended Bitcoin private key
- * @return unspent BTC balance, or null if unable to determine balance
- */
- public Long getWalletBalance(String xprv58) {
- Context.propagate(bitcoinjContext);
- Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
- wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
-
- Coin balance = wallet.getBalance();
- if (balance == null)
- return null;
-
- return balance.value;
- }
-
- /**
- * Returns first unused receive address given 'm' BIP32 key.
- *
- * @param xprv58 BIP32 extended Bitcoin private key
- * @return Bitcoin P2PKH address
- * @throws BitcoinException if something went wrong
- */
- public String getUnusedReceiveAddress(String xprv58) throws BitcoinException {
- Context.propagate(bitcoinjContext);
- Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
- DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
-
- keyChain.setLookaheadSize(WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT);
- keyChain.maybeLookAhead();
-
- final int keyChainPathSize = keyChain.getAccountPath().size();
- List keys = new ArrayList<>(keyChain.getLeafKeys());
-
- int ki = 0;
- do {
- for (; ki < keys.size(); ++ki) {
- DeterministicKey dKey = keys.get(ki);
- List dKeyPath = dKey.getPath();
-
- // If keyChain is based on 'm', then make sure dKey is m/0/ki
- if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO)
- continue;
-
- // Check unspent
- Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
- byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
-
- List unspentOutputs = this.electrumX.getUnspentOutputs(script, false);
-
- /*
- * If there are no unspent outputs then either:
- * a) all the outputs have been spent
- * b) address has never been used
- *
- * For case (a) we want to remember not to check this address (key) again.
- */
-
- if (unspentOutputs.isEmpty()) {
- // If this is a known key that has been spent before, then we can skip asking for transaction history
- if (this.spentKeys.contains(dKey)) {
- wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) dKey);
- continue;
- }
-
- // Ask for transaction history - if it's empty then key has never been used
- List historicTransactionHashes = this.electrumX.getAddressTransactions(script, false);
-
- if (!historicTransactionHashes.isEmpty()) {
- // Fully spent key - case (a)
- this.spentKeys.add(dKey);
- wallet.getActiveKeyChain().markKeyAsUsed(dKey);
- } else {
- // Key never been used - case (b)
- return address.toString();
- }
- }
-
- // Key has unspent outputs, hence used, so no good to us
- this.spentKeys.remove(dKey);
- }
-
- // Generate some more keys
- keyChain.setLookaheadSize(keyChain.getLookaheadSize() + WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT);
- keyChain.maybeLookAhead();
-
- // This returns all keys, including those already in 'keys'
- List allLeafKeys = keyChain.getLeafKeys();
- // Add only new keys onto our list of keys to search
- List newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
- keys.addAll(newKeys);
- // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
-
- // Process new keys
- } while (true);
- }
-
- // UTXOProvider support
-
- static class WalletAwareUTXOProvider implements UTXOProvider {
- private static final int LOOKAHEAD_INCREMENT = 3;
-
- private final BTC btc;
- private final Wallet wallet;
-
- enum KeySearchMode {
- REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT;
- }
- private final KeySearchMode keySearchMode;
- private final DeterministicKeyChain keyChain;
-
- public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) {
- this.btc = btc;
- this.wallet = wallet;
- this.keySearchMode = keySearchMode;
- this.keyChain = this.wallet.getActiveKeyChain();
-
- // Set up wallet's key chain
- this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT);
- this.keyChain.maybeLookAhead();
- }
-
- public List getOpenTransactionOutputs(List keys) throws UTXOProviderException {
- List allUnspentOutputs = new ArrayList<>();
- final boolean coinbase = false;
-
- int ki = 0;
- do {
- boolean areAllKeysUnspent = true;
- boolean areAllKeysSpent = true;
-
- for (; ki < keys.size(); ++ki) {
- ECKey key = keys.get(ki);
-
- Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH);
- byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
-
- List unspentOutputs;
- try {
- unspentOutputs = btc.electrumX.getUnspentOutputs(script, false);
- } catch (BitcoinException e) {
- throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
- }
-
- /*
- * If there are no unspent outputs then either:
- * a) all the outputs have been spent
- * b) address has never been used
- *
- * For case (a) we want to remember not to check this address (key) again.
- */
-
- if (unspentOutputs.isEmpty()) {
- // If this is a known key that has been spent before, then we can skip asking for transaction history
- if (btc.spentKeys.contains(key)) {
- wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
- areAllKeysUnspent = false;
- continue;
- }
-
- // Ask for transaction history - if it's empty then key has never been used
- List historicTransactionHashes;
- try {
- historicTransactionHashes = btc.electrumX.getAddressTransactions(script, false);
- } catch (BitcoinException e) {
- throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address));
- }
-
- if (!historicTransactionHashes.isEmpty()) {
- // Fully spent key - case (a)
- btc.spentKeys.add(key);
- wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
- areAllKeysUnspent = false;
- } else {
- // Key never been used - case (b)
- areAllKeysSpent = false;
- }
-
- continue;
- }
-
- // If we reach here, then there's definitely at least one unspent key
- btc.spentKeys.remove(key);
- areAllKeysSpent = false;
-
- for (UnspentOutput unspentOutput : unspentOutputs) {
- List transactionOutputs;
- try {
- transactionOutputs = btc.getOutputs(unspentOutput.hash);
- } catch (BitcoinException e) {
- throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
- HashCode.fromBytes(unspentOutput.hash)));
- }
-
- TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
-
- UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
- Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
- transactionOutput.getScriptPubKey());
-
- allUnspentOutputs.add(utxo);
- }
- }
-
- if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent)
- || (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) {
- // Generate some more keys
- this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT);
- this.keyChain.maybeLookAhead();
-
- // This returns all keys, including those already in 'keys'
- List allLeafKeys = this.keyChain.getLeafKeys();
- // Add only new keys onto our list of keys to search
- List newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
- keys.addAll(newKeys);
- // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
- }
-
- // If we have processed all keys, then we're done
- } while (ki < keys.size());
-
- return allUnspentOutputs;
- }
-
- public int getChainHeadHeight() throws UTXOProviderException {
- try {
- return btc.electrumX.getCurrentHeight();
- } catch (BitcoinException e) {
- throw new UTXOProviderException("Unable to determine Bitcoin chain height");
- }
- }
-
- public NetworkParameters getParams() {
- return btc.params;
- }
- }
-
- // Utility methods for us
-
- private byte[] addressToScript(String base58Address) {
- Context.propagate(bitcoinjContext);
- Address address = Address.fromString(this.params, base58Address);
- return ScriptBuilder.createOutputScript(address).getProgram();
- }
-
-}
diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java
new file mode 100644
index 00000000..a8c6469a
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/Bitcoin.java
@@ -0,0 +1,195 @@
+package org.qortal.crosschain;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumMap;
+import java.util.Map;
+
+import org.bitcoinj.core.Context;
+import org.bitcoinj.core.NetworkParameters;
+import org.bitcoinj.params.MainNetParams;
+import org.bitcoinj.params.RegTestParams;
+import org.bitcoinj.params.TestNet3Params;
+import org.qortal.crosschain.ElectrumX.Server;
+import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
+import org.qortal.settings.Settings;
+
+public class Bitcoin extends Bitcoiny {
+
+ public static final String CURRENCY_CODE = "BTC";
+
+ // Temporary values until a dynamic fee system is written.
+ private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
+ private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
+ private static final long NEW_FEE_AMOUNT = 10_000L;
+
+ private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
+
+ private static final Map DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
+ static {
+ DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
+ DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
+ }
+
+ public enum BitcoinNet {
+ MAIN {
+ @Override
+ public NetworkParameters getParams() {
+ return MainNetParams.get();
+ }
+
+ @Override
+ public Collection getServers() {
+ return Arrays.asList(
+ // Servers chosen on NO BASIS WHATSOEVER from various sources!
+ new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
+ new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
+ new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
+ new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
+ new Server("electrumx.electricnewyear.net", Server.ConnectionType.SSL, 50002),
+ new Server("dxm.no-ip.biz", Server.ConnectionType.TCP, 50001),
+ new Server("kirsche.emzy.de", Server.ConnectionType.TCP, 50001),
+ new Server("2AZZARITA.hopto.org", Server.ConnectionType.TCP, 50001),
+ new Server("xtrum.com", Server.ConnectionType.TCP, 50001),
+ new Server("electrum.srvmin.network", Server.ConnectionType.TCP, 50001),
+ new Server("electrumx.alexridevski.net", Server.ConnectionType.TCP, 50001),
+ new Server("bitcoin.lukechilds.co", Server.ConnectionType.TCP, 50001),
+ new Server("electrum.poiuty.com", Server.ConnectionType.TCP, 50001),
+ new Server("horsey.cryptocowboys.net", Server.ConnectionType.TCP, 50001),
+ new Server("electrum.emzy.de", Server.ConnectionType.TCP, 50001),
+ new Server("electrum-server.ninja", Server.ConnectionType.TCP, 50081),
+ new Server("bitcoin.electrumx.multicoin.co", Server.ConnectionType.TCP, 50001),
+ new Server("esx.geekhosters.com", Server.ConnectionType.TCP, 50001),
+ new Server("bitcoin.grey.pw", Server.ConnectionType.TCP, 50003),
+ new Server("exs.ignorelist.com", Server.ConnectionType.TCP, 50001),
+ new Server("electrum.coinext.com.br", Server.ConnectionType.TCP, 50001),
+ new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001),
+ new Server("skbxmit.coinjoined.com", Server.ConnectionType.TCP, 50001),
+ new Server("alviss.coinjoined.com", Server.ConnectionType.TCP, 50001),
+ new Server("electrum2.privateservers.network", Server.ConnectionType.TCP, 50001),
+ new Server("electrumx.schulzemic.net", Server.ConnectionType.TCP, 50001),
+ new Server("bitcoins.sk", Server.ConnectionType.TCP, 56001),
+ new Server("node.mendonca.xyz", Server.ConnectionType.TCP, 50001),
+ new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001));
+ }
+
+ @Override
+ public String getGenesisHash() {
+ return "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
+ }
+
+ @Override
+ public long getP2shFee(Long timestamp) {
+ // TODO: This will need to be replaced with something better in the near future!
+ if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP)
+ return OLD_FEE_AMOUNT;
+
+ return NEW_FEE_AMOUNT;
+ }
+ },
+ TEST3 {
+ @Override
+ public NetworkParameters getParams() {
+ return TestNet3Params.get();
+ }
+
+ @Override
+ public Collection getServers() {
+ return Arrays.asList(
+ new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 60001),
+ new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 60002),
+ new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002),
+ new Server("testnet.qtornado.com", Server.ConnectionType.TCP, 51001),
+ new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002),
+ new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
+ new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
+ new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012));
+ }
+
+ @Override
+ public String getGenesisHash() {
+ return "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
+ }
+
+ @Override
+ public long getP2shFee(Long timestamp) {
+ return NON_MAINNET_FEE;
+ }
+ },
+ REGTEST {
+ @Override
+ public NetworkParameters getParams() {
+ return RegTestParams.get();
+ }
+
+ @Override
+ public Collection getServers() {
+ return Arrays.asList(
+ new Server("localhost", Server.ConnectionType.TCP, 50001),
+ new Server("localhost", Server.ConnectionType.SSL, 50002));
+ }
+
+ @Override
+ public String getGenesisHash() {
+ // This is unique to each regtest instance
+ return null;
+ }
+
+ @Override
+ public long getP2shFee(Long timestamp) {
+ return NON_MAINNET_FEE;
+ }
+ };
+
+ public abstract NetworkParameters getParams();
+ public abstract Collection getServers();
+ public abstract String getGenesisHash();
+ public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
+ }
+
+ private static Bitcoin instance;
+
+ private final BitcoinNet bitcoinNet;
+
+ // Constructors and instance
+
+ private Bitcoin(BitcoinNet bitcoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
+ super(blockchain, bitcoinjContext, currencyCode);
+ this.bitcoinNet = bitcoinNet;
+
+ LOGGER.info(() -> String.format("Starting Bitcoin support using %s", this.bitcoinNet.name()));
+ }
+
+ public static synchronized Bitcoin getInstance() {
+ if (instance == null) {
+ BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
+
+ BitcoinyBlockchainProvider electrumX = new ElectrumX("Bitcoin-" + bitcoinNet.name(), bitcoinNet.getGenesisHash(), bitcoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
+ Context bitcoinjContext = new Context(bitcoinNet.getParams());
+
+ instance = new Bitcoin(bitcoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
+ }
+
+ return instance;
+ }
+
+ // Getters & setters
+
+ public static synchronized void resetForTesting() {
+ instance = null;
+ }
+
+ // Actual useful methods for use by other classes
+
+ /**
+ * Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp.
+ *
+ * @param timestamp optional milliseconds since epoch, or null for 'now'
+ * @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong
+ */
+ @Override
+ public long getP2shFee(Long timestamp) throws ForeignBlockchainException {
+ return this.bitcoinNet.getP2shFee(timestamp);
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java
similarity index 95%
rename from src/main/java/org/qortal/crosschain/BTCACCT.java
rename to src/main/java/org/qortal/crosschain/BitcoinACCTv1.java
index 1e803c52..5118e103 100644
--- a/src/main/java/org/qortal/crosschain/BTCACCT.java
+++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java
@@ -1,13 +1,10 @@
package org.qortal.crosschain;
-import static java.util.Arrays.stream;
-import static java.util.stream.Collectors.toMap;
import static org.ciyam.at.OpCode.calcOffset;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.List;
-import java.util.Map;
import org.ciyam.at.API;
import org.ciyam.at.CompilationException;
@@ -101,11 +98,12 @@ import com.google.common.primitives.Bytes;
*
*
*/
-public class BTCACCT {
+public class BitcoinACCTv1 implements ACCT {
+
+ public static final String NAME = BitcoinACCTv1.class.getSimpleName();
+ public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
public static final int SECRET_LENGTH = 32;
- public static final int MIN_LOCKTIME = 1500000000;
- public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
/** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
private static final int MODE_VALUE_OFFSET = 68;
@@ -126,22 +124,31 @@ public class BTCACCT {
public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
- public enum Mode {
- OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
+ private static BitcoinACCTv1 instance;
- public final int value;
- private static final Map map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode));
-
- Mode(int value) {
- this.value = value;
- }
-
- public static Mode valueOf(int value) {
- return map.get(value);
- }
+ private BitcoinACCTv1() {
}
- private BTCACCT() {
+ public static synchronized BitcoinACCTv1 getInstance() {
+ if (instance == null)
+ instance = new BitcoinACCTv1();
+
+ return instance;
+ }
+
+ @Override
+ public byte[] getCodeBytesHash() {
+ return CODE_BYTES_HASH;
+ }
+
+ @Override
+ public int getModeByteOffset() {
+ return MODE_BYTE_OFFSET;
+ }
+
+ @Override
+ public ForeignBlockchain getBlockchain() {
+ return Bitcoin.getInstance();
}
/**
@@ -156,7 +163,6 @@ public class BTCACCT {
* @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
* @param bitcoinAmount how much BTC the AT creator is expecting to trade
* @param tradeTimeout suggested timeout for entire trade
- * @return
*/
public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) {
// Labels for data segment addresses
@@ -419,7 +425,7 @@ public class BTCACCT {
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
// Partner address is AT creator's address, so cancel offer and finish.
- codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value));
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value));
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
codeByteBuffer.put(OpCode.FIN_IMD.compile());
@@ -470,7 +476,7 @@ public class BTCACCT {
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
/* We are in 'trade mode' */
- codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.TRADING.value));
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
// Set restart position to after this opcode
codeByteBuffer.put(OpCode.SET_PCS.compile());
@@ -568,7 +574,7 @@ public class BTCACCT {
// Pay AT's balance to receiving address
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
// Set redeemed mode
- codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value));
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value));
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
codeByteBuffer.put(OpCode.FIN_IMD.compile());
@@ -578,7 +584,7 @@ public class BTCACCT {
labelRefund = codeByteBuffer.position();
// Set refunded mode
- codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REFUNDED.value));
+ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value));
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
codeByteBuffer.put(OpCode.FIN_IMD.compile());
} catch (CompilationException e) {
@@ -591,7 +597,7 @@ public class BTCACCT {
byte[] codeBytes = new byte[codeByteBuffer.limit()];
codeByteBuffer.get(codeBytes);
- assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH)
+ assert Arrays.equals(Crypto.digest(codeBytes), BitcoinACCTv1.CODE_BYTES_HASH)
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
final short ciyamAtVersion = 2;
@@ -604,41 +610,34 @@ public class BTCACCT {
/**
* Returns CrossChainTradeData with useful info extracted from AT.
- *
- * @param repository
- * @param atAddress
- * @throws DataException
*/
- public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
+ @Override
+ public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
}
/**
* Returns CrossChainTradeData with useful info extracted from AT.
- *
- * @param repository
- * @param atAddress
- * @throws DataException
*/
- public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
+ @Override
+ public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
}
/**
* Returns CrossChainTradeData with useful info extracted from AT.
- *
- * @param repository
- * @param atAddress
- * @throws DataException
*/
- public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
+ public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
byte[] addressBytes = new byte[25]; // for general use
String atAddress = atStateData.getATAddress();
CrossChainTradeData tradeData = new CrossChainTradeData();
+ tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name();
+ tradeData.acctName = NAME;
+
tradeData.qortalAtAddress = atAddress;
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
tradeData.creationTimestamp = creationTimestamp;
@@ -658,9 +657,9 @@ public class BTCACCT {
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
// Creator's Bitcoin/foreign public key hash
- tradeData.creatorBitcoinPKH = new byte[20];
- dataByteBuffer.get(tradeData.creatorBitcoinPKH);
- dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes
+ tradeData.creatorForeignPKH = new byte[20];
+ dataByteBuffer.get(tradeData.creatorForeignPKH);
+ dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
// Hash of secret B
tradeData.hashOfSecretB = new byte[20];
@@ -671,7 +670,7 @@ public class BTCACCT {
tradeData.qortAmount = dataByteBuffer.getLong();
// Expected BTC amount
- tradeData.expectedBitcoin = dataByteBuffer.getLong();
+ tradeData.expectedForeignAmount = dataByteBuffer.getLong();
// Trade timeout
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
@@ -784,26 +783,28 @@ public class BTCACCT {
// Trade AT's 'mode'
long modeValue = dataByteBuffer.getLong();
- Mode mode = Mode.valueOf((int) (modeValue & 0xffL));
+ AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL));
/* End of variables */
- if (mode != null && mode != Mode.OFFERING) {
- tradeData.mode = mode;
+ if (acctMode != null && acctMode != AcctMode.OFFERING) {
+ tradeData.mode = acctMode;
tradeData.refundTimeout = refundTimeout;
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
tradeData.qortalPartnerAddress = qortalRecipient;
tradeData.hashOfSecretA = hashOfSecretA;
- tradeData.partnerBitcoinPKH = partnerBitcoinPKH;
+ tradeData.partnerForeignPKH = partnerBitcoinPKH;
tradeData.lockTimeA = lockTimeA;
tradeData.lockTimeB = lockTimeB;
- if (mode == Mode.REDEEMED)
+ if (acctMode == AcctMode.REDEEMED)
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
} else {
- tradeData.mode = Mode.OFFERING;
+ tradeData.mode = AcctMode.OFFERING;
}
+ tradeData.duplicateDeprecated();
+
return tradeData;
}
@@ -843,7 +844,8 @@ public class BTCACCT {
}
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
- public static byte[] buildCancelMessage(String creatorQortalAddress) {
+ @Override
+ public byte[] buildCancelMessage(String creatorQortalAddress) {
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
@@ -866,7 +868,7 @@ public class BTCACCT {
/** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) {
- // lockTimeB is halfway between offerMessageTimesamp and lockTimeA
+ // lockTimeB is halfway between offerMessageTimestamp and lockTimeA
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
}
diff --git a/src/main/java/org/qortal/crosschain/BitcoinException.java b/src/main/java/org/qortal/crosschain/BitcoinException.java
deleted file mode 100644
index 01db9d49..00000000
--- a/src/main/java/org/qortal/crosschain/BitcoinException.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.qortal.crosschain;
-
-@SuppressWarnings("serial")
-public class BitcoinException extends Exception {
-
- public BitcoinException() {
- super();
- }
-
- public BitcoinException(String message) {
- super(message);
- }
-
- public static class NetworkException extends BitcoinException {
- private final Integer daemonErrorCode;
-
- public NetworkException() {
- super();
- this.daemonErrorCode = null;
- }
-
- public NetworkException(String message) {
- super(message);
- this.daemonErrorCode = null;
- }
-
- public NetworkException(int errorCode, String message) {
- super(message);
- this.daemonErrorCode = errorCode;
- }
-
- public Integer getDaemonErrorCode() {
- return this.daemonErrorCode;
- }
- }
-
- public static class NotFoundException extends BitcoinException {
- public NotFoundException() {
- super();
- }
-
- public NotFoundException(String message) {
- super(message);
- }
- }
-
- public static class InsufficientFundsException extends BitcoinException {
- public InsufficientFundsException() {
- super();
- }
-
- public InsufficientFundsException(String message) {
- super(message);
- }
- }
-
-}
diff --git a/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java b/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java
deleted file mode 100644
index 0e22e27a..00000000
--- a/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.qortal.crosschain;
-
-import java.util.List;
-
-interface BitcoinNetworkProvider {
-
- /** Returns current blockchain height. */
- int getCurrentHeight() throws BitcoinException;
-
- /** Returns a list of raw block headers, starting at startHeight (inclusive), up to count max. */
- List getRawBlockHeaders(int startHeight, int count) throws BitcoinException;
-
- /** Returns balance of address represented by scriptPubKey. */
- long getConfirmedBalance(byte[] scriptPubKey) throws BitcoinException;
-
- /** Returns raw, serialized, transaction bytes given txHash. */
- byte[] getRawTransaction(String txHash) throws BitcoinException;
-
- /** Returns unpacked transaction given txHash. */
- BitcoinTransaction getTransaction(String txHash) throws BitcoinException;
-
- /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */
- List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException;
-
- /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */
- List getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException;
-
- /** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */
- boolean broadcastTransaction(byte[] rawTransaction) throws BitcoinException;
-
-}
diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java
new file mode 100644
index 00000000..1201b363
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java
@@ -0,0 +1,704 @@
+package org.qortal.crosschain;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.bitcoinj.core.Address;
+import org.bitcoinj.core.AddressFormatException;
+import org.bitcoinj.core.Coin;
+import org.bitcoinj.core.Context;
+import org.bitcoinj.core.ECKey;
+import org.bitcoinj.core.InsufficientMoneyException;
+import org.bitcoinj.core.LegacyAddress;
+import org.bitcoinj.core.NetworkParameters;
+import org.bitcoinj.core.Sha256Hash;
+import org.bitcoinj.core.Transaction;
+import org.bitcoinj.core.TransactionOutput;
+import org.bitcoinj.core.UTXO;
+import org.bitcoinj.core.UTXOProvider;
+import org.bitcoinj.core.UTXOProviderException;
+import org.bitcoinj.crypto.ChildNumber;
+import org.bitcoinj.crypto.DeterministicHierarchy;
+import org.bitcoinj.crypto.DeterministicKey;
+import org.bitcoinj.script.Script.ScriptType;
+import org.bitcoinj.script.ScriptBuilder;
+import org.bitcoinj.wallet.DeterministicKeyChain;
+import org.bitcoinj.wallet.SendRequest;
+import org.bitcoinj.wallet.Wallet;
+import org.qortal.api.model.SimpleForeignTransaction;
+import org.qortal.crypto.Crypto;
+import org.qortal.utils.Amounts;
+import org.qortal.utils.BitTwiddling;
+
+import com.google.common.hash.HashCode;
+
+/** Bitcoin-like (Bitcoin, Litecoin, etc.) support */
+public abstract class Bitcoiny implements ForeignBlockchain {
+
+ protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class);
+
+ public static final int HASH160_LENGTH = 20;
+
+ protected final BitcoinyBlockchainProvider blockchain;
+ protected final Context bitcoinjContext;
+ protected final String currencyCode;
+
+ protected final NetworkParameters params;
+
+ /** Keys that have been previously marked as fully spent,
+ * i.e. keys with transactions but with no unspent outputs. */
+ protected final Set spentKeys = Collections.synchronizedSet(new HashSet<>());
+
+ /** How many bitcoinj wallet keys to generate in each batch. */
+ private static final int WALLET_KEY_LOOKAHEAD_INCREMENT = 3;
+
+ /** Byte offset into raw block headers to block timestamp. */
+ private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
+
+ // Constructors and instance
+
+ protected Bitcoiny(BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
+ this.blockchain = blockchain;
+ this.bitcoinjContext = bitcoinjContext;
+ this.currencyCode = currencyCode;
+
+ this.params = this.bitcoinjContext.getParams();
+ }
+
+ // Getters & setters
+
+ public BitcoinyBlockchainProvider getBlockchainProvider() {
+ return this.blockchain;
+ }
+
+ public Context getBitcoinjContext() {
+ return this.bitcoinjContext;
+ }
+
+ public String getCurrencyCode() {
+ return this.currencyCode;
+ }
+
+ public NetworkParameters getNetworkParameters() {
+ return this.params;
+ }
+
+ // Interface obligations
+
+ @Override
+ public boolean isValidAddress(String address) {
+ try {
+ ScriptType addressType = Address.fromString(this.params, address).getOutputScriptType();
+
+ return addressType == ScriptType.P2PKH || addressType == ScriptType.P2SH;
+ } catch (AddressFormatException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean isValidWalletKey(String walletKey) {
+ return this.isValidDeterministicKey(walletKey);
+ }
+
+ // Actual useful methods for use by other classes
+
+ public String format(Coin amount) {
+ return this.format(amount.value);
+ }
+
+ public String format(long amount) {
+ return Amounts.prettyAmount(amount) + " " + this.currencyCode;
+ }
+
+ public boolean isValidDeterministicKey(String key58) {
+ try {
+ Context.propagate(this.bitcoinjContext);
+ DeterministicKey.deserializeB58(null, key58, this.params);
+ return true;
+ } catch (IllegalArgumentException e) {
+ return false;
+ }
+ }
+
+ /** Returns P2PKH address using passed public key hash. */
+ public String pkhToAddress(byte[] publicKeyHash) {
+ Context.propagate(this.bitcoinjContext);
+ return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
+ }
+
+ /** Returns P2SH address using passed redeem script. */
+ public String deriveP2shAddress(byte[] redeemScriptBytes) {
+ Context.propagate(bitcoinjContext);
+ byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
+ return LegacyAddress.fromScriptHash(this.params, redeemScriptHash).toString();
+ }
+
+ /**
+ * Returns median timestamp from latest 11 blocks, in seconds.
+ *
+ * @throws ForeignBlockchainException if error occurs
+ */
+ public int getMedianBlockTime() throws ForeignBlockchainException {
+ int height = this.blockchain.getCurrentHeight();
+
+ // Grab latest 11 blocks
+ List blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11);
+ if (blockHeaders.size() < 11)
+ throw new ForeignBlockchainException("Not enough blocks to determine median block time");
+
+ List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
+
+ // Descending order
+ blockTimestamps.sort((a, b) -> Integer.compare(b, a));
+
+ // Pick median
+ return blockTimestamps.get(5);
+ }
+
+ /** Returns fee per transaction KB. To be overridden for testnet/regtest. */
+ public Coin getFeePerKb() {
+ return this.bitcoinjContext.getFeePerKb();
+ }
+
+ /**
+ * Returns fixed P2SH spending fee, in sats per 1000bytes, optionally for historic timestamp.
+ *
+ * @param timestamp optional milliseconds since epoch, or null for 'now'
+ * @return sats per 1000bytes
+ * @throws ForeignBlockchainException if something went wrong
+ */
+ public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
+
+ /**
+ * Returns confirmed balance, based on passed payment script.
+ *
+ * @return confirmed balance, or zero if script unknown
+ * @throws ForeignBlockchainException if there was an error
+ */
+ public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException {
+ return this.blockchain.getConfirmedBalance(addressToScriptPubKey(base58Address));
+ }
+
+ /**
+ * Returns list of unspent outputs pertaining to passed address.
+ *
+ * @return list of unspent outputs, or empty list if address unknown
+ * @throws ForeignBlockchainException if there was an error.
+ */
+ // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
+ public List getUnspentOutputs(String base58Address) throws ForeignBlockchainException {
+ List unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false);
+
+ List unspentTransactionOutputs = new ArrayList<>();
+ for (UnspentOutput unspentOutput : unspentOutputs) {
+ List transactionOutputs = this.getOutputs(unspentOutput.hash);
+
+ unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
+ }
+
+ return unspentTransactionOutputs;
+ }
+
+ /**
+ * Returns list of outputs pertaining to passed transaction hash.
+ *
+ * @return list of outputs, or empty list if transaction unknown
+ * @throws ForeignBlockchainException if there was an error.
+ */
+ // TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
+ public List getOutputs(byte[] txHash) throws ForeignBlockchainException {
+ byte[] rawTransactionBytes = this.blockchain.getRawTransaction(txHash);
+
+ Context.propagate(bitcoinjContext);
+ Transaction transaction = new Transaction(this.params, rawTransactionBytes);
+ return transaction.getOutputs();
+ }
+
+ /**
+ * Returns list of transaction hashes pertaining to passed address.
+ *
+ * @return list of unspent outputs, or empty list if script unknown
+ * @throws ForeignBlockchainException if there was an error.
+ */
+ public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException {
+ return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed);
+ }
+
+ /**
+ * Returns list of raw, confirmed transactions involving given address.
+ *
+ * @throws ForeignBlockchainException if there was an error
+ */
+ public List getAddressTransactions(String base58Address) throws ForeignBlockchainException {
+ List transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false);
+
+ List rawTransactions = new ArrayList<>();
+ for (TransactionHash transactionInfo : transactionHashes) {
+ byte[] rawTransaction = this.blockchain.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes());
+ rawTransactions.add(rawTransaction);
+ }
+
+ return rawTransactions;
+ }
+
+ /**
+ * Returns transaction info for passed transaction hash.
+ *
+ * @throws ForeignBlockchainException.NotFoundException if transaction unknown
+ * @throws ForeignBlockchainException if error occurs
+ */
+ public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException {
+ return this.blockchain.getTransaction(txHash);
+ }
+
+ /**
+ * Broadcasts raw transaction to network.
+ *
+ * @throws ForeignBlockchainException if error occurs
+ */
+ public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException {
+ this.blockchain.broadcastTransaction(transaction.bitcoinSerialize());
+ }
+
+ /**
+ * Returns bitcoinj transaction sending amount to recipient.
+ *
+ * @param xprv58 BIP32 private key
+ * @param recipient P2PKH address
+ * @param amount unscaled amount
+ * @param feePerByte unscaled fee per byte, or null to use default fees
+ * @return transaction, or null if insufficient funds
+ */
+ public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) {
+ Context.propagate(bitcoinjContext);
+
+ Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
+ wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
+
+ Address destination = Address.fromString(this.params, recipient);
+ SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
+
+ if (feePerByte != null)
+ sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024
+ else
+ // Allow override of default for TestNet3, etc.
+ sendRequest.feePerKb = this.getFeePerKb();
+
+ try {
+ wallet.completeTx(sendRequest);
+ return sendRequest.tx;
+ } catch (InsufficientMoneyException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns bitcoinj transaction sending amount to recipient using default fees.
+ *
+ * @param xprv58 BIP32 private key
+ * @param recipient P2PKH address
+ * @param amount unscaled amount
+ * @return transaction, or null if insufficient funds
+ */
+ public Transaction buildSpend(String xprv58, String recipient, long amount) {
+ return buildSpend(xprv58, recipient, amount, null);
+ }
+
+ /**
+ * Returns unspent Bitcoin balance given 'm' BIP32 key.
+ *
+ * @param key58 BIP32/HD extended Bitcoin private/public key
+ * @return unspent BTC balance, or null if unable to determine balance
+ */
+ public Long getWalletBalance(String key58) {
+ Context.propagate(bitcoinjContext);
+
+ Wallet wallet = walletFromDeterministicKey58(key58);
+ wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
+
+ Coin balance = wallet.getBalance();
+ if (balance == null)
+ return null;
+
+ return balance.value;
+ }
+
+ public List getWalletTransactions(String key58) throws ForeignBlockchainException {
+ Context.propagate(bitcoinjContext);
+
+ Wallet wallet = walletFromDeterministicKey58(key58);
+ DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
+
+ keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
+ keyChain.maybeLookAhead();
+
+ List keys = new ArrayList<>(keyChain.getLeafKeys());
+
+ Set walletTransactions = new HashSet<>();
+
+ int ki = 0;
+ do {
+ boolean areAllKeysUnused = true;
+
+ for (; ki < keys.size(); ++ki) {
+ DeterministicKey dKey = keys.get(ki);
+
+ // Check for transactions
+ Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
+ byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
+
+ // Ask for transaction history - if it's empty then key has never been used
+ List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
+
+ if (!historicTransactionHashes.isEmpty()) {
+ areAllKeysUnused = false;
+
+ for (TransactionHash transactionHash : historicTransactionHashes)
+ walletTransactions.add(this.getTransaction(transactionHash.txHash));
+ }
+ }
+
+ if (areAllKeysUnused)
+ // No transactions for this batch of keys so assume we're done searching.
+ break;
+
+ // Generate some more keys
+ keys.addAll(generateMoreKeys(keyChain));
+
+ // Process new keys
+ } while (true);
+
+ return walletTransactions.stream().collect(Collectors.toList());
+ }
+
+ /**
+ * Returns first unused receive address given 'm' BIP32 key.
+ *
+ * @param key58 BIP32/HD extended Bitcoin private/public key
+ * @return P2PKH address
+ * @throws ForeignBlockchainException if something went wrong
+ */
+ public String getUnusedReceiveAddress(String key58) throws ForeignBlockchainException {
+ Context.propagate(bitcoinjContext);
+
+ Wallet wallet = walletFromDeterministicKey58(key58);
+ DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
+
+ keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
+ keyChain.maybeLookAhead();
+
+ final int keyChainPathSize = keyChain.getAccountPath().size();
+ List keys = new ArrayList<>(keyChain.getLeafKeys());
+
+ int ki = 0;
+ do {
+ for (; ki < keys.size(); ++ki) {
+ DeterministicKey dKey = keys.get(ki);
+ List dKeyPath = dKey.getPath();
+
+ // If keyChain is based on 'm', then make sure dKey is m/0/ki - i.e. a 'receive' address, not 'change' (m/1/ki)
+ if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO)
+ continue;
+
+ // Check unspent
+ Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
+ byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
+
+ List unspentOutputs = this.blockchain.getUnspentOutputs(script, false);
+
+ /*
+ * If there are no unspent outputs then either:
+ * a) all the outputs have been spent
+ * b) address has never been used
+ *
+ * For case (a) we want to remember not to check this address (key) again.
+ */
+
+ if (unspentOutputs.isEmpty()) {
+ // If this is a known key that has been spent before, then we can skip asking for transaction history
+ if (this.spentKeys.contains(dKey)) {
+ wallet.getActiveKeyChain().markKeyAsUsed(dKey);
+ continue;
+ }
+
+ // Ask for transaction history - if it's empty then key has never been used
+ List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
+
+ if (!historicTransactionHashes.isEmpty()) {
+ // Fully spent key - case (a)
+ this.spentKeys.add(dKey);
+ wallet.getActiveKeyChain().markKeyAsUsed(dKey);
+ continue;
+ }
+
+ // Key never been used - case (b)
+ return address.toString();
+ }
+
+ // Key has unspent outputs, hence used, so no good to us
+ this.spentKeys.remove(dKey);
+ }
+
+ // Generate some more keys
+ keys.addAll(generateMoreKeys(keyChain));
+
+ // Process new keys
+ } while (true);
+ }
+
+ // UTXOProvider support
+
+ static class WalletAwareUTXOProvider implements UTXOProvider {
+ private final Bitcoiny bitcoiny;
+ private final Wallet wallet;
+
+ private final DeterministicKeyChain keyChain;
+
+ public WalletAwareUTXOProvider(Bitcoiny bitcoiny, Wallet wallet) {
+ this.bitcoiny = bitcoiny;
+ this.wallet = wallet;
+ this.keyChain = this.wallet.getActiveKeyChain();
+
+ // Set up wallet's key chain
+ this.keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
+ this.keyChain.maybeLookAhead();
+ }
+
+ @Override
+ public List getOpenTransactionOutputs(List keys) throws UTXOProviderException {
+ List allUnspentOutputs = new ArrayList<>();
+ final boolean coinbase = false;
+
+ int ki = 0;
+ do {
+ boolean areAllKeysUnspent = true;
+
+ for (; ki < keys.size(); ++ki) {
+ ECKey key = keys.get(ki);
+
+ Address address = Address.fromKey(this.bitcoiny.params, key, ScriptType.P2PKH);
+ byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
+
+ List unspentOutputs;
+ try {
+ unspentOutputs = this.bitcoiny.blockchain.getUnspentOutputs(script, false);
+ } catch (ForeignBlockchainException e) {
+ throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
+ }
+
+ /*
+ * If there are no unspent outputs then either:
+ * a) all the outputs have been spent
+ * b) address has never been used
+ *
+ * For case (a) we want to remember not to check this address (key) again.
+ */
+
+ if (unspentOutputs.isEmpty()) {
+ // If this is a known key that has been spent before, then we can skip asking for transaction history
+ if (this.bitcoiny.spentKeys.contains(key)) {
+ this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
+ areAllKeysUnspent = false;
+ continue;
+ }
+
+ // Ask for transaction history - if it's empty then key has never been used
+ List historicTransactionHashes;
+ try {
+ historicTransactionHashes = this.bitcoiny.blockchain.getAddressTransactions(script, false);
+ } catch (ForeignBlockchainException e) {
+ throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address));
+ }
+
+ if (!historicTransactionHashes.isEmpty()) {
+ // Fully spent key - case (a)
+ this.bitcoiny.spentKeys.add(key);
+ this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
+ areAllKeysUnspent = false;
+ } else {
+ // Key never been used - case (b)
+ }
+
+ continue;
+ }
+
+ // If we reach here, then there's definitely at least one unspent key
+ this.bitcoiny.spentKeys.remove(key);
+
+ for (UnspentOutput unspentOutput : unspentOutputs) {
+ List transactionOutputs;
+ try {
+ transactionOutputs = this.bitcoiny.getOutputs(unspentOutput.hash);
+ } catch (ForeignBlockchainException e) {
+ throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
+ HashCode.fromBytes(unspentOutput.hash)));
+ }
+
+ TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
+
+ UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
+ Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
+ transactionOutput.getScriptPubKey());
+
+ allUnspentOutputs.add(utxo);
+ }
+ }
+
+ if (areAllKeysUnspent)
+ // No transactions for this batch of keys so assume we're done searching.
+ return allUnspentOutputs;
+
+ // Generate some more keys
+ keys.addAll(Bitcoiny.generateMoreKeys(this.keyChain));
+
+ // Process new keys
+ } while (true);
+ }
+
+ @Override
+ public int getChainHeadHeight() throws UTXOProviderException {
+ try {
+ return this.bitcoiny.blockchain.getCurrentHeight();
+ } catch (ForeignBlockchainException e) {
+ throw new UTXOProviderException("Unable to determine Bitcoiny chain height");
+ }
+ }
+
+ @Override
+ public NetworkParameters getParams() {
+ return this.bitcoiny.params;
+ }
+ }
+
+ // Utility methods for others
+
+ public static List simplifyWalletTransactions(List transactions) {
+ // Sort by oldest timestamp first
+ transactions.sort(Comparator.comparingInt(t -> t.timestamp));
+
+ // Manual 2nd-level sort same-timestamp transactions so that a transaction's input comes first
+ int fromIndex = 0;
+ do {
+ int timestamp = transactions.get(fromIndex).timestamp;
+
+ int toIndex;
+ for (toIndex = fromIndex + 1; toIndex < transactions.size(); ++toIndex)
+ if (transactions.get(toIndex).timestamp != timestamp)
+ break;
+
+ // Process same-timestamp sub-list
+ List subList = transactions.subList(fromIndex, toIndex);
+
+ // Only if necessary
+ if (subList.size() > 1) {
+ // Quick index lookup
+ Map indexByTxHash = subList.stream().collect(Collectors.toMap(t -> t.txHash, t -> t.timestamp));
+
+ int restartIndex = 0;
+ boolean isSorted;
+ do {
+ isSorted = true;
+
+ for (int ourIndex = restartIndex; ourIndex < subList.size(); ++ourIndex) {
+ BitcoinyTransaction ourTx = subList.get(ourIndex);
+
+ for (BitcoinyTransaction.Input input : ourTx.inputs) {
+ Integer inputIndex = indexByTxHash.get(input.outputTxHash);
+
+ if (inputIndex != null && inputIndex > ourIndex) {
+ // Input tx is currently after current tx, so swap
+ BitcoinyTransaction tmpTx = subList.get(inputIndex);
+ subList.set(inputIndex, ourTx);
+ subList.set(ourIndex, tmpTx);
+
+ // Update index lookup too
+ indexByTxHash.put(ourTx.txHash, inputIndex);
+ indexByTxHash.put(tmpTx.txHash, ourIndex);
+
+ if (isSorted)
+ restartIndex = Math.max(restartIndex, ourIndex);
+
+ isSorted = false;
+ break;
+ }
+ }
+ }
+ } while (!isSorted);
+ }
+
+ fromIndex = toIndex;
+ } while (fromIndex < transactions.size());
+
+ // Simplify
+ List simpleTransactions = new ArrayList<>();
+
+ // Quick lookup of txs in our wallet
+ Set walletTxHashes = transactions.stream().map(t -> t.txHash).collect(Collectors.toSet());
+
+ for (BitcoinyTransaction transaction : transactions) {
+ SimpleForeignTransaction.Builder builder = new SimpleForeignTransaction.Builder();
+ builder.txHash(transaction.txHash);
+ builder.timestamp(transaction.timestamp);
+
+ builder.isSentNotReceived(false);
+
+ for (BitcoinyTransaction.Input input : transaction.inputs) {
+ // TODO: add input via builder
+
+ if (walletTxHashes.contains(input.outputTxHash))
+ builder.isSentNotReceived(true);
+ }
+
+ for (BitcoinyTransaction.Output output : transaction.outputs)
+ builder.output(output.addresses, output.value);
+
+ simpleTransactions.add(builder.build());
+ }
+
+ return simpleTransactions;
+ }
+
+ // Utility methods for us
+
+ protected static List generateMoreKeys(DeterministicKeyChain keyChain) {
+ int existingLeafKeyCount = keyChain.getLeafKeys().size();
+
+ // Increase lookahead size...
+ keyChain.setLookaheadSize(keyChain.getLookaheadSize() + Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
+ // ...and lookahead threshold (minimum number of keys to generate)...
+ keyChain.setLookaheadThreshold(0);
+ // ...so that this call will generate more keys
+ keyChain.maybeLookAhead();
+
+ // This returns *all* keys
+ List allLeafKeys = keyChain.getLeafKeys();
+
+ // Only return newly generated keys
+ return allLeafKeys.subList(existingLeafKeyCount, allLeafKeys.size());
+ }
+
+ protected byte[] addressToScriptPubKey(String base58Address) {
+ Context.propagate(this.bitcoinjContext);
+ Address address = Address.fromString(this.params, base58Address);
+ return ScriptBuilder.createOutputScript(address).getProgram();
+ }
+
+ protected Wallet walletFromDeterministicKey58(String key58) {
+ DeterministicKey dKey = DeterministicKey.deserializeB58(null, key58, this.params);
+
+ if (dKey.hasPrivKey())
+ return Wallet.fromSpendingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
+ else
+ return Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
+ }
+
+}
diff --git a/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java
new file mode 100644
index 00000000..7691efb1
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java
@@ -0,0 +1,40 @@
+package org.qortal.crosschain;
+
+import java.util.List;
+
+public abstract class BitcoinyBlockchainProvider {
+
+ public static final boolean INCLUDE_UNCONFIRMED = true;
+ public static final boolean EXCLUDE_UNCONFIRMED = false;
+
+ /** Returns ID unique to bitcoiny network (e.g. "Litecoin-TEST3") */
+ public abstract String getNetId();
+
+ /** Returns current blockchain height. */
+ public abstract int getCurrentHeight() throws ForeignBlockchainException;
+
+ /** Returns a list of raw block headers, starting at startHeight (inclusive), up to count max. */
+ public abstract List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException;
+
+ /** Returns balance of address represented by scriptPubKey. */
+ public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException;
+
+ /** Returns raw, serialized, transaction bytes given txHash. */
+ public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException;
+
+ /** Returns raw, serialized, transaction bytes given txHash. */
+ public abstract byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException;
+
+ /** Returns unpacked transaction given txHash. */
+ public abstract BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException;
+
+ /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */
+ public abstract List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
+
+ /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */
+ public abstract List getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
+
+ /** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */
+ public abstract void broadcastTransaction(byte[] rawTransaction) throws ForeignBlockchainException;
+
+}
diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java
similarity index 61%
rename from src/main/java/org/qortal/crosschain/BTCP2SH.java
rename to src/main/java/org/qortal/crosschain/BitcoinyHTLC.java
index ef59ee4d..af93091f 100644
--- a/src/main/java/org/qortal/crosschain/BTCP2SH.java
+++ b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java
@@ -4,6 +4,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
@@ -29,7 +30,7 @@ import org.qortal.utils.BitTwiddling;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
-public class BTCP2SH {
+public class BitcoinyHTLC {
public enum Status {
UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED
@@ -38,6 +39,34 @@ public class BTCP2SH {
public static final int SECRET_LENGTH = 32;
public static final int MIN_LOCKTIME = 1500000000;
+ public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
+ public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
+
+ // Assuming node's trade-bot has no more than 100 entries?
+ private static final int MAX_CACHE_ENTRIES = 100;
+
+ // Max time-to-live for cache entries (milliseconds)
+ private static final long CACHE_TIMEOUT = 30_000L;
+
+ @SuppressWarnings("serial")
+ private static final Map SECRET_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
+ // This method is called just after a new entry has been added
+ @Override
+ public boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > MAX_CACHE_ENTRIES;
+ }
+ };
+ private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0];
+
+ @SuppressWarnings("serial")
+ private static final Map STATUS_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
+ // This method is called just after a new entry has been added
+ @Override
+ public boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > MAX_CACHE_ENTRIES;
+ }
+ };
+
/*
* OP_TUCK (to copy public key to before signature)
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
@@ -62,24 +91,24 @@ public class BTCP2SH {
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
/**
- * Returns Bitcoin redeemScript used for cross-chain trading.
+ * Returns redeemScript used for cross-chain trading.
*
- * See comments in {@link BTCP2SH} for more details.
+ * See comments in {@link BitcoinyHTLC} for more details.
*
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
* @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
- * @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
- * @return
+ * @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
*/
- public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
+ public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] hashOfSecret) {
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
- redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
+ redeemScript3, redeemerPubKeyHash, redeemScript4, hashOfSecret, redeemScript5);
}
/**
- * Builds a custom transaction to spend P2SH.
+ * Builds a custom transaction to spend HTLC P2SH.
*
+ * @param params blockchain network parameters
* @param amount output amount, should be total of input amounts, less miner fees
* @param spendKey key for signing transaction, and also where funds are 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
@@ -87,12 +116,11 @@ public class BTCP2SH {
* @param lockTime (optional) transaction nLockTime, used in refund scenario
* @param scriptSigBuilder function for building scriptSig using transaction input signature
* @param outputPublicKeyHash PKH used to create P2PKH output
- * @return Signed Bitcoin transaction for spending P2SH
+ * @return Signed transaction for spending P2SH
*/
- public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes,
+ public static Transaction buildP2shTransaction(NetworkParameters params, Coin amount, ECKey spendKey,
+ List fundingOutputs, byte[] redeemScriptBytes,
Long lockTime, Function scriptSigBuilder, byte[] outputPublicKeyHash) {
- NetworkParameters params = BTC.getInstance().getNetworkParameters();
-
Transaction transaction = new Transaction(params);
transaction.setVersion(2);
@@ -105,9 +133,9 @@ public class BTCP2SH {
// Input (without scriptSig prior to signing)
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
if (lockTime != null)
- input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
+ input.setSequenceNumber(LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
else
- input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
+ input.setSequenceNumber(NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
transaction.addInput(input);
}
@@ -134,17 +162,19 @@ public class BTCP2SH {
}
/**
- * Returns signed Bitcoin transaction claiming refund from P2SH address.
+ * Returns signed transaction claiming refund from HTLC P2SH.
*
+ * @param params blockchain network parameters
* @param refundAmount refund amount, should be total of input amounts, less miner fees
- * @param refundKey key for signing transaction, and also where refund is 'sent' (output)
- * @param fundingOutput output from transaction that funded P2SH address
+ * @param refundKey key for signing transaction
+ * @param fundingOutputs outputs from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
- * @param receivingAccountInfo Bitcoin PKH used for output
- * @return Signed Bitcoin transaction for refunding P2SH
+ * @param receivingAccountInfo public-key-hash used for P2PKH output
+ * @return Signed transaction for refunding P2SH
*/
- public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) {
+ public static Transaction buildRefundTransaction(NetworkParameters params, Coin refundAmount, ECKey refundKey,
+ List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) {
Function refundSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
@@ -163,21 +193,23 @@ public class BTCP2SH {
};
// Send funds back to funding address
- return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo);
+ return buildP2shTransaction(params, refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo);
}
/**
- * Returns signed Bitcoin transaction redeeming funds from P2SH address.
+ * Returns signed transaction redeeming funds from P2SH address.
*
+ * @param params blockchain network parameters
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
- * @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
- * @param fundingOutput output from transaction that funded P2SH address
+ * @param redeemKey key for signing transaction
+ * @param fundingOutputs outputs from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param secret actual 32-byte secret used when building redeemScript
* @param receivingAccountInfo Bitcoin PKH used for output
- * @return Signed Bitcoin transaction for redeeming P2SH
+ * @return Signed transaction for redeeming P2SH
*/
- public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
+ public static Transaction buildRedeemTransaction(NetworkParameters params, Coin redeemAmount, ECKey redeemKey,
+ List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
Function redeemSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
@@ -198,17 +230,28 @@ public class BTCP2SH {
return scriptBuilder.build();
};
- return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
+ return buildP2shTransaction(params, redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
}
- /** Returns 'secret', if any, given list of raw bitcoin transactions. */
- public static byte[] findP2shSecret(String p2shAddress, List rawTransactions) {
- NetworkParameters params = BTC.getInstance().getNetworkParameters();
+ /**
+ * Returns 'secret', if any, given HTLC's P2SH address.
+ *
+ * @throws ForeignBlockchainException
+ */
+ public static byte[] findHtlcSecret(Bitcoiny bitcoiny, String p2shAddress) throws ForeignBlockchainException {
+ NetworkParameters params = bitcoiny.getNetworkParameters();
+ String compoundKey = String.format("%s-%s-%d", params.getId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
+
+ byte[] secret = SECRET_CACHE.getOrDefault(compoundKey, NO_SECRET_CACHE_ENTRY);
+ if (secret != NO_SECRET_CACHE_ENTRY)
+ return secret;
+
+ List rawTransactions = bitcoiny.getAddressTransactions(p2shAddress);
for (byte[] rawTransaction : rawTransactions) {
Transaction transaction = new Transaction(params, rawTransaction);
- // Cycle through inputs, looking for one that spends our P2SH
+ // Cycle through inputs, looking for one that spends our HTLC
for (TransactionInput input : transaction.getInputs()) {
Script scriptSig = input.getScriptSig();
List scriptChunks = scriptSig.getChunks();
@@ -230,92 +273,115 @@ public class BTCP2SH {
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
if (!inputAddress.toString().equals(p2shAddress))
- // Input isn't spending our P2SH
+ // Input isn't spending our HTLC
continue;
- byte[] secret = scriptChunks.get(0).data;
- if (secret.length != BTCP2SH.SECRET_LENGTH)
+ secret = scriptChunks.get(0).data;
+ if (secret.length != BitcoinyHTLC.SECRET_LENGTH)
continue;
+ // Cache secret for a while
+ SECRET_CACHE.put(compoundKey, secret);
+
return secret;
}
}
+ // Cache negative result
+ SECRET_CACHE.put(compoundKey, null);
+
return null;
}
- /** Returns P2SH status, given P2SH address and expected redeem/refund amount, or throws BitcoinException if error occurs. */
- public static Status determineP2shStatus(String p2shAddress, long minimumAmount) throws BitcoinException {
- final BTC btc = BTC.getInstance();
+ /**
+ * Returns HTLC status, given P2SH address and expected redeem/refund amount
+ *
+ * @throws ForeignBlockchainException if error occurs
+ */
+ public static Status determineHtlcStatus(BitcoinyBlockchainProvider blockchain, String p2shAddress, long minimumAmount) throws ForeignBlockchainException {
+ String compoundKey = String.format("%s-%s-%d", blockchain.getNetId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
- List transactionHashes = btc.getAddressTransactions(p2shAddress, BTC.INCLUDE_UNCONFIRMED);
+ Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null);
+ if (cachedStatus != null)
+ return cachedStatus;
+
+ byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress);
+ List transactionHashes = blockchain.getAddressTransactions(ourScriptPubKey, BitcoinyBlockchainProvider.INCLUDE_UNCONFIRMED);
// Sort by confirmed first, followed by ascending height
transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight));
// Transaction cache
- Map transactionsByHash = new HashMap<>();
+ Map transactionsByHash = new HashMap<>();
// HASH160(redeem script) for this p2shAddress
byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress);
// Check for spends first, caching full transaction info as we progress just in case we don't return in this loop
for (TransactionHash transactionInfo : transactionHashes) {
- BitcoinTransaction bitcoinTransaction = btc.getTransaction(transactionInfo.txHash);
+ BitcoinyTransaction bitcoinyTransaction = blockchain.getTransaction(transactionInfo.txHash);
// Cache for possible later reuse
- transactionsByHash.put(transactionInfo.txHash, bitcoinTransaction);
+ transactionsByHash.put(transactionInfo.txHash, bitcoinyTransaction);
// Acceptable funding is one transaction output, so we're expecting only one input
- if (bitcoinTransaction.inputs.size() != 1)
+ if (bitcoinyTransaction.inputs.size() != 1)
// Wrong number of inputs
continue;
- String scriptSig = bitcoinTransaction.inputs.get(0).scriptSig;
+ String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig;
List scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes());
if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4)
- // Not spending one of these P2SH
+ // Not valid chunks for our form of HTLC
continue;
// Last chunk is redeem script
byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1);
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash))
- // Not spending our specific P2SH
+ // Not spending our specific HTLC redeem script
continue;
- // If we have 4 chunks, then secret is present
- return scriptSigChunks.size() == 4
- ? (transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED)
- : (transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED);
+ if (scriptSigChunks.size() == 4)
+ // If we have 4 chunks, then secret is present, hence redeem
+ cachedStatus = transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED;
+ else
+ cachedStatus = transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED;
+
+ STATUS_CACHE.put(compoundKey, cachedStatus);
+ return cachedStatus;
}
- String ourScriptPubKey = HashCode.fromBytes(addressToScriptPubKey(p2shAddress)).toString();
+ String ourScriptPubKeyHex = HashCode.fromBytes(ourScriptPubKey).toString();
// Check for funding
for (TransactionHash transactionInfo : transactionHashes) {
- BitcoinTransaction bitcoinTransaction = transactionsByHash.get(transactionInfo.txHash);
- if (bitcoinTransaction == null)
+ BitcoinyTransaction bitcoinyTransaction = transactionsByHash.get(transactionInfo.txHash);
+ if (bitcoinyTransaction == null)
// Should be present in map!
- throw new BitcoinException("Cached Bitcoin transaction now missing?");
+ throw new ForeignBlockchainException("Cached Bitcoin transaction now missing?");
// Check outputs for our specific P2SH
- for (BitcoinTransaction.Output output : bitcoinTransaction.outputs) {
+ for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) {
// Check amount
if (output.value < minimumAmount)
// Output amount too small (not taking fees into account)
continue;
- String scriptPubKey = output.scriptPubKey;
- if (!scriptPubKey.equals(ourScriptPubKey))
+ String scriptPubKeyHex = output.scriptPubKey;
+ if (!scriptPubKeyHex.equals(ourScriptPubKeyHex))
// Not funding our specific P2SH
continue;
- return transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED;
+ cachedStatus = transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED;
+ STATUS_CACHE.put(compoundKey, cachedStatus);
+ return cachedStatus;
}
}
- return Status.UNFUNDED;
+ cachedStatus = Status.UNFUNDED;
+ STATUS_CACHE.put(compoundKey, cachedStatus);
+ return cachedStatus;
}
private static List extractScriptSigChunks(byte[] scriptSigBytes) {
diff --git a/src/main/java/org/qortal/crosschain/BitcoinTransaction.java b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java
similarity index 53%
rename from src/main/java/org/qortal/crosschain/BitcoinTransaction.java
rename to src/main/java/org/qortal/crosschain/BitcoinyTransaction.java
index 05516bc4..caf0b36d 100644
--- a/src/main/java/org/qortal/crosschain/BitcoinTransaction.java
+++ b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java
@@ -3,20 +3,43 @@ package org.qortal.crosschain;
import java.util.List;
import java.util.stream.Collectors;
-public class BitcoinTransaction {
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlTransient;
+
+@XmlAccessorType(XmlAccessType.FIELD)
+public class BitcoinyTransaction {
public final String txHash;
+
+ @XmlTransient
public final int size;
+
+ @XmlTransient
public final int locktime;
+
// Not present if transaction is unconfirmed
public final Integer timestamp;
public static class Input {
+ @XmlTransient
public final String scriptSig;
+
+ @XmlTransient
public final int sequence;
+
public final String outputTxHash;
+
public final int outputVout;
+ // For JAXB
+ protected Input() {
+ this.scriptSig = null;
+ this.sequence = 0;
+ this.outputTxHash = null;
+ this.outputVout = 0;
+ }
+
public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) {
this.scriptSig = scriptSig;
this.sequence = sequence;
@@ -29,15 +52,34 @@ public class BitcoinTransaction {
this.outputTxHash, this.outputVout, this.sequence, this.scriptSig);
}
}
+ @XmlTransient
public final List inputs;
public static class Output {
+ @XmlTransient
public final String scriptPubKey;
+
public final long value;
+ public final List addresses;
+
+ // For JAXB
+ protected Output() {
+ this.scriptPubKey = null;
+ this.value = 0;
+ this.addresses = null;
+ }
+
public Output(String scriptPubKey, long value) {
this.scriptPubKey = scriptPubKey;
this.value = value;
+ this.addresses = null;
+ }
+
+ public Output(String scriptPubKey, long value, List addresses) {
+ this.scriptPubKey = scriptPubKey;
+ this.value = value;
+ this.addresses = addresses;
}
public String toString() {
@@ -46,7 +88,20 @@ public class BitcoinTransaction {
}
public final List