From 79641efa877f9c9d20e7e09d09f6f5861c99f96c Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 10 Sep 2020 12:03:37 +0100 Subject: [PATCH] Tighten up trade-bot, ElectrumX Added separate method to determine status of P2SH transactions, returning UNFUNDED, FUNDING_IN_PROGRESS, REDEEMED, etc. Added code to trade-bot to increase robustness. Lots more changes including unified state change/logging, checking for existing MESSAGEs, etc. Added missing websocket methods to silence log noise. Trade-bot now called per block during synchronization, instead of per batch, to pick up edge cases where some potential trade-bot transitions were missed, resulting in failed trades. Corresponding changes in Controller, such as notifying event bus of new block in same thread (thus blocking) instead of using executor. Added slightly more robust common block determination to Synchronizer. Refactored code in BTC class to use new BitcoinException rather than simply returning null, with added sub-classes allowing differentiation between network issues or fund issues. Changed BTC.buildSpend to try harder to find UXTOs to address false "insufficient funds" issues. Repository change to add index on MessageTransactions for quicker look-up of trade-related messages. Reduced reliance on bitcoinj library in BTCP2SH. Reworked ElectrumX to better detect errors rather than continuously try more servers to no avail. Also added genesis block check in case of servers on different Bitcoin networks. Now tries to extract upstream bitcoind error codes and pass those up to caller via exceptions. Updated list of testnet servers. MemoryPoW now detects thread interrupt and exits fast. Moved some non-generic transaction-related repository methods to their own subclass. For example: moved TransactionRepository.getMessagesByRecipient to MessageRepository.getMessagesByParticipants Updated and added more tests. --- .../api/model/CrossChainOfferSummary.java | 6 + .../api/model/TradeBotRespondRequest.java | 6 +- .../api/resource/CrossChainResource.java | 41 +- .../api/websocket/ActiveChatsWebSocket.java | 4 + .../api/websocket/ChatMessagesWebSocket.java | 4 + .../api/websocket/TradeBotWebSocket.java | 8 + .../api/websocket/TradeOffersWebSocket.java | 84 +- .../java/org/qortal/block/BlockChain.java | 19 +- .../org/qortal/controller/BlockMinter.java | 17 +- .../org/qortal/controller/Controller.java | 32 +- .../org/qortal/controller/Synchronizer.java | 35 +- .../java/org/qortal/controller/TradeBot.java | 900 ++++++++++-------- src/main/java/org/qortal/crosschain/BTC.java | 161 +++- .../java/org/qortal/crosschain/BTCACCT.java | 3 +- .../java/org/qortal/crosschain/BTCP2SH.java | 135 +++ .../qortal/crosschain/BitcoinException.java | 57 ++ .../crosschain/BitcoinNetworkProvider.java | 31 + .../qortal/crosschain/BitcoinTransaction.java | 70 ++ .../java/org/qortal/crosschain/ElectrumX.java | 367 +++++-- .../qortal/crosschain/TransactionHash.java | 31 + .../org/qortal/crosschain/UnspentOutput.java | 16 + .../java/org/qortal/crypto/MemoryPoW.java | 4 + .../java/org/qortal/data/block/BlockData.java | 19 + .../qortal/data/crosschain/TradeBotData.java | 5 + .../qortal/repository/MessageRepository.java | 31 + .../org/qortal/repository/Repository.java | 2 + .../repository/TransactionRepository.java | 13 - .../hsqldb/HSQLDBDatabaseUpdates.java | 5 + .../hsqldb/HSQLDBMessageRepository.java | 85 ++ .../repository/hsqldb/HSQLDBRepository.java | 6 + .../HSQLDBTransactionRepository.java | 38 - .../java/org/qortal/settings/Settings.java | 7 + src/test/java/org/qortal/test/BlockTests.java | 44 + .../org/qortal/test/btcacct/BtcTests.java | 7 +- .../org/qortal/test/btcacct/CheckP2SH.java | 11 +- .../qortal/test/btcacct/ElectrumXTests.java | 97 +- .../qortal/test/btcacct/GetTransaction.java | 9 +- .../org/qortal/test/btcacct/P2shTests.java | 53 ++ .../java/org/qortal/test/btcacct/Redeem.java | 24 +- .../java/org/qortal/test/btcacct/Refund.java | 24 +- 40 files changed, 1787 insertions(+), 724 deletions(-) create mode 100644 src/main/java/org/qortal/crosschain/BitcoinException.java create mode 100644 src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java create mode 100644 src/main/java/org/qortal/crosschain/BitcoinTransaction.java create mode 100644 src/main/java/org/qortal/crosschain/TransactionHash.java create mode 100644 src/main/java/org/qortal/crosschain/UnspentOutput.java create mode 100644 src/main/java/org/qortal/repository/MessageRepository.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java create mode 100644 src/test/java/org/qortal/test/btcacct/P2shTests.java diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java index 4cabfc37..7f17e02a 100644 --- a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -83,4 +83,10 @@ public class CrossChainOfferSummary { return this.partnerQortalReceivingAddress; } + // For debugging mostly + + public String toString() { + return String.format("%s: %s", this.qortalAtAddress, this.mode.name()); + } + } diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java index 4e947a9b..129b6c7e 100644 --- a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java @@ -8,13 +8,13 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class TradeBotRespondRequest { - @Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy") + @Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") public String atAddress; - @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") + @Schema(description = "Bitcoin BIP32 extended private key", example = "xprv___________________________________________________________________________________________________________") public String xprv58; - @Schema(description = "Qortal address for receiving QORT from AT") + @Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq") public String receivingAddress; public TradeBotRespondRequest() { diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 5828b2a2..c8ab6527 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -58,6 +58,7 @@ import org.qortal.controller.TradeBot; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; @@ -602,17 +603,12 @@ public class CrossChainResource { Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); - if (medianBlockTime == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + int medianBlockTime = BTC.getInstance().getMedianBlockTime(); long now = NTP.getTime(); // Check P2SH is funded - - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus(); p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); @@ -634,6 +630,8 @@ public class CrossChainResource { return p2shStatus; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (BitcoinException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); } } @@ -746,9 +744,7 @@ public class CrossChainResource { // Check P2SH is funded - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); if (fundingOutputs.isEmpty()) @@ -764,14 +760,14 @@ public class CrossChainResource { Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundRequest.receivingAccountInfo); - boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); + BTC.getInstance().broadcastTransaction(refundTransaction); - if (!wasBroadcast) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); return refundTransaction.getTxId().toString(); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (BitcoinException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); } } @@ -884,16 +880,12 @@ public class CrossChainResource { Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); - if (medianBlockTime == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + int medianBlockTime = BTC.getInstance().getMedianBlockTime(); long now = NTP.getTime(); // Check P2SH is funded - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); if (p2shBalance < crossChainTradeData.expectedBitcoin) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); @@ -909,14 +901,14 @@ public class CrossChainResource { Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo); - boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); - if (!wasBroadcast) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + BTC.getInstance().broadcastTransaction(redeemTransaction); return redeemTransaction.getTxId().toString(); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } catch (BitcoinException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); } } @@ -1001,8 +993,11 @@ public class CrossChainResource { if (spendTransaction == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - if (!BTC.getInstance().broadcastTransaction(spendTransaction)) + try { + BTC.getInstance().broadcastTransaction(spendTransaction); + } catch (BitcoinException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + } return "true"; } diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index 1f541e36..405fe7e5 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -31,6 +31,7 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } @OnWebSocketConnect + @Override public void onWebSocketConnect(Session session) { Map pathParams = getPathParams(session, "/{address}"); @@ -49,16 +50,19 @@ public class ActiveChatsWebSocket extends ApiWebSocket { } @OnWebSocketClose + @Override public void onWebSocketClose(Session session, int statusCode, String reason) { ChatNotifier.getInstance().deregister(session); } @OnWebSocketError public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ } @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { + /* ignored */ } private void onNotify(Session session, ChatTransactionData chatTransactionData, String ourAddress, AtomicReference previousOutput) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index beaa9ad5..57ef1504 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -32,6 +32,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { } @OnWebSocketConnect + @Override public void onWebSocketConnect(Session session) { Map> queryParams = session.getUpgradeRequest().getParameterMap(); @@ -86,16 +87,19 @@ public class ChatMessagesWebSocket extends ApiWebSocket { } @OnWebSocketClose + @Override public void onWebSocketClose(Session session, int statusCode, String reason) { ChatNotifier.getInstance().deregister(session); } @OnWebSocketError public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ } @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { + /* ignored */ } private void onNotify(Session session, ChatTransactionData chatTransactionData, int txGroupId) { diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java index e97e54bc..a52b7d8b 100644 --- a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -11,6 +11,7 @@ import java.util.stream.Collectors; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; import org.eclipse.jetty.websocket.api.annotations.WebSocket; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; @@ -71,6 +72,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { } @OnWebSocketConnect + @Override public void onWebSocketConnect(Session session) { // Send all known trade-bot entries try (final Repository repository = RepositoryManager.getRepository()) { @@ -92,10 +94,16 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { } @OnWebSocketClose + @Override public void onWebSocketClose(Session session, int statusCode, String reason) { super.onWebSocketClose(session, statusCode, reason); } + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ + } + @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { /* ignored */ diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 622c005f..740d7f5d 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -9,9 +9,12 @@ import java.util.Map; import java.util.function.Predicate; import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; import org.eclipse.jetty.websocket.api.annotations.WebSocket; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; @@ -33,15 +36,14 @@ import org.qortal.utils.NTP; @SuppressWarnings("serial") public class TradeOffersWebSocket extends ApiWebSocket implements Listener { + private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class); + private static final Map previousAtModes = new HashMap<>(); // OFFERING - private static final List currentSummaries = new ArrayList<>(); + private static final Map currentSummaries = new HashMap<>(); // REDEEMED/REFUNDED/CANCELLED - private static final List historicSummaries = new ArrayList<>(); - - private static final Predicate isCurrent = offerSummary - -> offerSummary.getMode() == BTCACCT.Mode.OFFERING; + private static final Map historicSummaries = new HashMap<>(); private static final Predicate isHistoric = offerSummary -> offerSummary.getMode() == BTCACCT.Mode.REDEEMED @@ -104,27 +106,33 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { return; // Update - previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode))); + for (CrossChainOfferSummary offerSummary : crossChainOfferSummaries) { + previousAtModes.put(offerSummary.qortalAtAddress, offerSummary.getMode()); + LOGGER.trace(() -> String.format("Block height: %d, AT: %s, mode: %s", blockData.getHeight(), offerSummary.qortalAtAddress, offerSummary.getMode().name())); - // Find 'historic' (REDEEMED/REFUNDED/CANCELLED) entries for use below: - List historicOffers = crossChainOfferSummaries.stream().filter(isHistoric).collect(Collectors.toList()); + switch (offerSummary.getMode()) { + case OFFERING: + currentSummaries.put(offerSummary.qortalAtAddress, offerSummary); + historicSummaries.remove(offerSummary.qortalAtAddress); + break; - synchronized (currentSummaries) { - // Add any OFFERING to 'current' - currentSummaries.addAll(crossChainOfferSummaries.stream().filter(isCurrent).collect(Collectors.toList())); + case REDEEMED: + case REFUNDED: + case CANCELLED: + currentSummaries.remove(offerSummary.qortalAtAddress); + historicSummaries.put(offerSummary.qortalAtAddress, offerSummary); + break; - // Remove any offers that have become REDEEMED/REFUNDED/CANCELLED - currentSummaries.removeAll(historicOffers); + case TRADING: + currentSummaries.remove(offerSummary.qortalAtAddress); + historicSummaries.remove(offerSummary.qortalAtAddress); + break; + } } + // Remove any historic offers that are over 24 hours old final long tooOldTimestamp = NTP.getTime() - 24 * 60 * 60 * 1000L; - synchronized (historicSummaries) { - // Add any REDEEMED/REFUNDED/CANCELLED - historicSummaries.addAll(historicOffers); - - // But also remove any that are over 24 hours old - historicSummaries.removeIf(offerSummary -> offerSummary.getTimestamp() < tooOldTimestamp); - } + historicSummaries.values().removeIf(historicSummary -> historicSummary.getTimestamp() < tooOldTimestamp); } // Notify sessions @@ -138,17 +146,15 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { Map> queryParams = session.getUpgradeRequest().getParameterMap(); final boolean includeHistoric = queryParams.get("includeHistoric") != null; - List crossChainOfferSummaries; + List crossChainOfferSummaries = new ArrayList<>(); - synchronized (currentSummaries) { - crossChainOfferSummaries = new ArrayList<>(currentSummaries); + synchronized (previousAtModes) { + crossChainOfferSummaries.addAll(currentSummaries.values()); + + if (includeHistoric) + crossChainOfferSummaries.addAll(historicSummaries.values()); } - if (includeHistoric) - synchronized (historicSummaries) { - crossChainOfferSummaries.addAll(historicSummaries); - } - if (!sendOfferSummaries(session, crossChainOfferSummaries)) { session.close(4002, "websocket issue"); return; @@ -163,6 +169,11 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { super.onWebSocketClose(session, statusCode, reason); } + @OnWebSocketError + public void onWebSocketError(Session session, Throwable throwable) { + /* ignored */ + } + @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { /* ignored */ @@ -201,7 +212,7 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING))); // Convert to offer summaries - currentSummaries.addAll(produceSummaries(repository, initialAtStates, null)); + currentSummaries.putAll(produceSummaries(repository, initialAtStates, null).stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary))); } private static void populateHistoricSummaries(Repository repository) throws DataException { @@ -227,21 +238,14 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { for (ATStateData historicAtState : historicAtStates) { CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null); - switch (historicOfferSummary.getMode()) { - case REDEEMED: - case REFUNDED: - case CANCELLED: - break; - - default: - continue; - } + if (!isHistoric.test(historicOfferSummary)) + continue; // Add summary to initial burst - historicSummaries.add(historicOfferSummary); + historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary); // Save initial AT mode - previousAtModes.put(historicAtState.getATAddress(), historicOfferSummary.getMode()); + previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode()); } } diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 0aa6860b..809e678b 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -554,17 +554,22 @@ public class BlockChain { try { try (final Repository repository = RepositoryManager.getRepository()) { - for (int height = repository.getBlockRepository().getBlockchainHeight(); height > targetHeight; --height) { + int height = repository.getBlockRepository().getBlockchainHeight(); + BlockData orphanBlockData = repository.getBlockRepository().fromHeight(height); + + while (height > targetHeight) { LOGGER.info(String.format("Forcably orphaning block %d", height)); - BlockData blockData = repository.getBlockRepository().fromHeight(height); - Block block = new Block(repository, blockData); + Block block = new Block(repository, orphanBlockData); block.orphan(); - repository.saveChanges(); - } - BlockData lastBlockData = repository.getBlockRepository().getLastBlock(); - Controller.getInstance().setChainTip(lastBlockData); + repository.saveChanges(); + + --height; + orphanBlockData = repository.getBlockRepository().fromHeight(height); + + Controller.getInstance().onNewBlock(orphanBlockData); + } return true; } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 643c7baa..aa80246d 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -275,9 +275,10 @@ public class BlockMinter extends Thread { try { newBlock.process(); - LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight())); repository.saveChanges(); + LOGGER.info(String.format("Minted new block: %d", newBlock.getBlockData().getHeight())); + RewardShareData rewardShareData = repository.getAccountRepository().getRewardShare(newBlock.getBlockData().getMinterPublicKey()); if (rewardShareData != null) { @@ -293,9 +294,7 @@ public class BlockMinter extends Thread { newBlock.getMinter().getAddress())); } - repository.saveChanges(); - - // Notify controller + // Notify controller after we're released blockchain lock newBlockMinted = true; } catch (DataException e) { // Unable to process block - report and discard @@ -306,8 +305,14 @@ public class BlockMinter extends Thread { blockchainLock.unlock(); } - if (newBlockMinted) - Controller.getInstance().onNewBlock(newBlock.getBlockData()); + if (newBlockMinted) { + BlockData newBlockData = newBlock.getBlockData(); + // Notify Controller and broadcast our new chain to network + Controller.getInstance().onNewBlock(newBlockData); + + Network network = Network.getInstance(); + network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData)); + } } } catch (DataException e) { LOGGER.warn("Repository issue while running block minter", e); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index fd1ddc5d..50db9763 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -371,6 +371,9 @@ public class Controller extends Thread { blockMinter = new BlockMinter(); blockMinter.start(); + LOGGER.info("Starting trade-bot"); + TradeBot.getInstance(); + // Arbitrary transaction data manager // LOGGER.info("Starting arbitrary-transaction data manager"); // ArbitraryDataManager.getInstance().start(); @@ -638,6 +641,9 @@ public class Controller extends Thread { // Update chain-tip, systray, notify peers, websockets, etc. this.onNewBlock(newChainTip); + + Network network = Network.getInstance(); + network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newChainTip)); } return syncResult; @@ -821,25 +827,19 @@ public class Controller extends Thread { } public void onNewBlock(BlockData latestBlockData) { - this.setChainTip(latestBlockData); + // Protective copy + BlockData blockDataCopy = new BlockData(latestBlockData); + + this.setChainTip(blockDataCopy); requestSysTrayUpdate = true; - // Broadcast our new height info and notify websocket listeners - this.callbackExecutor.execute(() -> { - Network network = Network.getInstance(); - network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); + // Notify listeners, trade-bot, etc. + EventBus.INSTANCE.notify(new NewBlockEvent(blockDataCopy)); - // Notify listeners of new block - EventBus.INSTANCE.notify(new NewBlockEvent(latestBlockData)); - - if (this.notifyGroupMembershipChange) { - this.notifyGroupMembershipChange = false; - ChatNotifier.getInstance().onGroupMembershipChange(); - } - - // Trade-bot might want to perform some actions too - TradeBot.getInstance().onChainTipChange(); - }); + if (this.notifyGroupMembershipChange) { + this.notifyGroupMembershipChange = false; + ChatNotifier.getInstance().onGroupMembershipChange(); + } } /** Callback for when we've received a new transaction via API or peer. */ diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index d55fef47..8dca5b05 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -241,15 +241,15 @@ public class Synchronizer { blockSummariesFromCommon.addAll(blockSummariesBatch); // Trim summaries so that first summary is common block. - // Currently we work back from the end until we hit a block we also have. + // Currently we work forward from common block until we hit a block we don't have // TODO: rewrite as modified binary search! - for (int i = blockSummariesFromCommon.size() - 1; i > 0; --i) { - if (repository.getBlockRepository().exists(blockSummariesFromCommon.get(i).getSignature())) { - // Note: index i isn't cleared: List.subList is fromIndex inclusive to toIndex exclusive - blockSummariesFromCommon.subList(0, i).clear(); + int i; + for (i = 1; i < blockSummariesFromCommon.size(); ++i) + if (!repository.getBlockRepository().exists(blockSummariesFromCommon.get(i).getSignature())) break; - } - } + + // Note: index i - 1 isn't cleared: List.subList is fromIndex inclusive to toIndex exclusive + blockSummariesFromCommon.subList(0, i - 1).clear(); return SynchronizationResult.OK; } @@ -397,15 +397,20 @@ public class Synchronizer { // Unwind to common block (unless common block is our latest block) LOGGER.debug(String.format("Orphaning blocks back to common block height %d, sig %.8s", commonBlockHeight, commonBlockSig58)); + BlockData orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight); while (ourHeight > commonBlockHeight) { if (Controller.isStopping()) return SynchronizationResult.SHUTTING_DOWN; - BlockData blockData = repository.getBlockRepository().fromHeight(ourHeight); - Block block = new Block(repository, blockData); + Block block = new Block(repository, orphanBlockData); block.orphan(); + repository.saveChanges(); + --ourHeight; + orphanBlockData = repository.getBlockRepository().fromHeight(ourHeight); + + Controller.getInstance().onNewBlock(orphanBlockData); } LOGGER.debug(String.format("Orphaned blocks back to height %d, sig %.8s - applying new blocks from peer %s", commonBlockHeight, commonBlockSig58, peer)); @@ -426,9 +431,9 @@ public class Synchronizer { newBlock.process(); - // If we've grown our blockchain then at least save progress so far - if (ourHeight > ourInitialHeight) - repository.saveChanges(); + repository.saveChanges(); + + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } return SynchronizationResult.OK; @@ -508,9 +513,9 @@ public class Synchronizer { newBlock.process(); - // If we've grown our blockchain then at least save progress so far - if (ourHeight > ourInitialHeight) - repository.saveChanges(); + repository.saveChanges(); + + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } return SynchronizationResult.OK; diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index f5cbd16b..e5494675 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -1,13 +1,14 @@ package org.qortal.controller; +import java.awt.TrayIcon.MessageType; import java.security.SecureRandom; import java.util.Arrays; import java.util.List; import java.util.Random; -import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.util.Supplier; import org.bitcoinj.core.Address; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; @@ -22,6 +23,7 @@ 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.crypto.Crypto; import org.qortal.data.account.AccountBalanceData; import org.qortal.data.at.ATData; @@ -32,10 +34,13 @@ 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; @@ -45,7 +50,17 @@ import org.qortal.utils.Amounts; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -public class TradeBot { +/** + * Performing cross-chain trading steps on behalf of user. + *

+ * We deal with three different independent state-spaces here: + *

    + *
  • Qortal blockchain
  • + *
  • Bitcoin blockchain
  • + *
  • Trade-bot entries
  • + *
+ */ +public class TradeBot implements Listener { public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } @@ -71,10 +86,8 @@ public class TradeBot { private static TradeBot instance; - /** To help ensure only TradeBot is only active on one thread. */ - private AtomicBoolean activeFlag = new AtomicBoolean(false); - private TradeBot() { + EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); } public static synchronized TradeBot getInstance() { @@ -172,11 +185,9 @@ public class TradeBot { secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress)); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), + () -> 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 { @@ -250,15 +261,20 @@ public class TradeBot { // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); - Long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - if (estimatedFee == null) { - LOGGER.debug(() -> String.format("Couldn't estimate Bitcoin fees?")); + long estimatedFee; + try { + estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + } catch (BitcoinException e) { + LOGGER.debug("Couldn't estimate Bitcoin fees?"); return ResponseResult.BTC_NETWORK_ISSUE; } - long totalFundsRequired = crossChainTradeData.expectedBitcoin + estimatedFee /* P2SH-A */ - + P2SH_B_OUTPUT_AMOUNT + estimatedFee /* P2SH-B */; + // 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 totalFundsRequired = fundsRequiredForP2shA + fundsRequiredForP2shB; + // As buildSpend also adds a fee, this is more pessimistic than required Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) return ResponseResult.INSUFFICIENT_FUNDS; @@ -268,23 +284,26 @@ public class TradeBot { String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); // Fund P2SH-A - Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + estimatedFee); + + // 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*/; + + Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, amountA); if (p2shFundingTransaction == null) { - LOGGER.warn(() -> String.format("Unable to build P2SH-A funding transaction - lack of funds?")); + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); return ResponseResult.BTC_BALANCE_ISSUE; } - if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { + try { + BTC.getInstance().broadcastTransaction(p2shFundingTransaction); + } catch (BitcoinException e) { // We couldn't fund P2SH-A at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?")); + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); return ResponseResult.BTC_NETWORK_ISSUE; } - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), + () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); return ResponseResult.OK; } @@ -309,75 +328,75 @@ public class TradeBot { return secret; } - public void onChainTipChange() { - // No point doing anything on old/stale data - if (!Controller.getInstance().isUpToDate()) + @Override + public void listen(Event event) { + if (!(event instanceof Controller.NewBlockEvent)) return; - if (!activeFlag.compareAndSet(false, true)) - // Trade bot already active on another thread - return; + synchronized (this) { + // Get repo for trade situations + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - // Get repo for trade situations - try (final Repository repository = RepositoryManager.getRepository()) { - List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + for (TradeBotData tradeBotData : allTradeBotData) { + repository.discardChanges(); - for (TradeBotData tradeBotData : allTradeBotData) { - repository.discardChanges(); + try { + switch (tradeBotData.getState()) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; - switch (tradeBotData.getState()) { - case BOB_WAITING_FOR_AT_CONFIRM: - handleBobWaitingForAtConfirm(repository, tradeBotData); - break; + case ALICE_WAITING_FOR_P2SH_A: + handleAliceWaitingForP2shA(repository, tradeBotData); + break; - case ALICE_WAITING_FOR_P2SH_A: - handleAliceWaitingForP2shA(repository, tradeBotData); - break; + case BOB_WAITING_FOR_MESSAGE: + handleBobWaitingForMessage(repository, tradeBotData); + break; - case BOB_WAITING_FOR_MESSAGE: - handleBobWaitingForMessage(repository, tradeBotData); - break; + case ALICE_WAITING_FOR_AT_LOCK: + handleAliceWaitingForAtLock(repository, tradeBotData); + break; - case ALICE_WAITING_FOR_AT_LOCK: - handleAliceWaitingForAtLock(repository, tradeBotData); - break; + case BOB_WAITING_FOR_P2SH_B: + handleBobWaitingForP2shB(repository, tradeBotData); + break; - case BOB_WAITING_FOR_P2SH_B: - handleBobWaitingForP2shB(repository, tradeBotData); - break; + case ALICE_WATCH_P2SH_B: + handleAliceWatchingP2shB(repository, tradeBotData); + break; - case ALICE_WATCH_P2SH_B: - handleAliceWatchingP2shB(repository, tradeBotData); - break; + case BOB_WAITING_FOR_AT_REDEEM: + handleBobWaitingForAtRedeem(repository, tradeBotData); + break; - case BOB_WAITING_FOR_AT_REDEEM: - handleBobWaitingForAtRedeem(repository, tradeBotData); - break; + case ALICE_DONE: + case BOB_DONE: + break; - case ALICE_DONE: - case BOB_DONE: - break; + case ALICE_REFUNDING_B: + handleAliceRefundingP2shB(repository, tradeBotData); + break; - case ALICE_REFUNDING_B: - handleAliceRefundingP2shB(repository, tradeBotData); - break; + case ALICE_REFUNDING_A: + handleAliceRefundingP2shA(repository, tradeBotData); + break; - case ALICE_REFUNDING_A: - handleAliceRefundingP2shA(repository, tradeBotData); - break; + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; - case ALICE_REFUNDED: - case BOB_REFUNDED: - break; - - default: - LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); + 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); } - } catch (DataException e) { - LOGGER.error("Couldn't run trade bot due to repository issue", e); - } finally { - activeFlag.set(false); } } @@ -395,6 +414,7 @@ public class TradeBot { // After this long we assume transaction loss so give up with trade-bot entry too. tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); tradeBotData.setTimestamp(NTP.getTime()); + // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); repository.saveChanges(); @@ -403,13 +423,8 @@ public class TradeBot { return; } - tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } /** @@ -427,8 +442,9 @@ public class TradeBot { *
  • 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 */ - private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -436,59 +452,71 @@ public class TradeBot { } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); // If AT has finished then maybe Bob cancelled his trade offer if (atData.getIsFinished()) { // No point sending MESSAGE - might as well wait for refund - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); - notifyStateChange(tradeBotData); - + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + () -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)); return; } - Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < crossChainTradeData.expectedBitcoin) { - if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-A balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + // 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); - return; + switch (p2shStatus) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT + updateTradeBotState(repository, tradeBotData, 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, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + case FUNDED: + // Fall-through out of switch... + break; } // P2SH-A funding confirmed // Attempt to send MESSAGE to Bob's Qortal trade address byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + 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); + messageTransaction.computeNonce(); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); - return; + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + return; + } } - tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", - p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, 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())); } /** @@ -508,8 +536,9 @@ public class TradeBot { *

    * 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 */ - private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { + 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) { @@ -519,19 +548,13 @@ public class TradeBot { // If AT has finished then Bob likely cancelled his trade offer if (atData.getIsFinished()) { - tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); - + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); return; } String address = tradeBotData.getTradeNativeAddress(); - List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); @@ -559,26 +582,36 @@ public class TradeBot { byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; byte[] hashOfSecretA = offerMessageData.hashOfSecretA; int lockTimeA = (int) offerMessageData.lockTimeA; + // Determine P2SH-A address and confirm funded - byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); + byte[] redeemScriptA = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); - Long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - if (estimatedFee == null) { - LOGGER.debug(() -> String.format("Couldn't estimate Bitcoin fees?")); - // Not worth trying other MESSAGEs... give up for now - return; - } + final long minimumAmountA = tradeBotData.getBitcoinAmount() - P2SH_B_OUTPUT_AMOUNT; - final long minimumBalance = tradeBotData.getBitcoinAmount() + estimatedFee; - Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < minimumBalance) { - // P2SH-A has no, or insufficient, balance - if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-A %s balance %s lower than expected %s", p2shAddress, BTC.format(balance), BTC.format(minimumBalance))); + BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); - // There might be another MESSAGE from someone else with an actually funded P2SH-A... - continue; + switch (p2shStatus) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // There might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A... + continue; + + case FUNDED: + // Fall-through out of switch... + break; } // Good to go - send MESSAGE to AT @@ -588,43 +621,38 @@ public class TradeBot { // 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); + String messageRecipient = tradeBotData.getAtAddress(); - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); + 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); + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", outgoingMessageTransaction.getRecipient(), result.name())); - return; + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } } - tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + byte[] redeemScriptB = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); - byte[] redeemScriptBytes = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); - String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - - LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress)); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + () -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB)); return; } - // Don't resave if we don't need to - if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) { - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - notifyStateChange(tradeBotData); - } + // Don't resave/notify if we don't need to + if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) + updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), null); } /** @@ -640,8 +668,9 @@ public class TradeBot { *

    * 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 */ - private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -651,20 +680,42 @@ public class TradeBot { // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; + BTCP2SH.Status p2shStatusA = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); - if (atData.getIsFinished()) - LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); - else - LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress)); + switch (p2shStatusA) { + 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, + () -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA)); + return; - notifyStateChange(tradeBotData); + 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, + () -> 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, + () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); + return; + + case FUNDED: + // Fall-through out of switch... + break; + } + + updateTradeBotState(repository, tradeBotData, 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; } @@ -680,19 +731,12 @@ public class TradeBot { byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", - tradeBotData.getAtAddress(), - crossChainTradeData.qortalPartnerAddress, - tradeBotData.getTradeNativeAddress(), - p2shAddress)); - - // There's no P2SH-B at this point, so jump straight to refunding P2SH-A - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - notifyStateChange(tradeBotData); + 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; } @@ -700,25 +744,14 @@ public class TradeBot { // Alice needs to fund P2SH-B here // Find our MESSAGE to AT from previous state - List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null); - if (messageTransactionsData == null) { - LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT %s from repository", crossChainTradeData.qortalCreatorTradeAddress)); - return; - } - - // Find our message - Long recipientMessageTimestamp = null; - for (MessageTransactionData messageTransactionData : messageTransactionsData) - if (Arrays.equals(messageTransactionData.getSenderPublicKey(), tradeBotData.getTradeNativePublicKey())) { - recipientMessageTimestamp = messageTransactionData.getTimestamp(); - break; - } - - if (recipientMessageTimestamp == null) { + 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 lockTimeA = tradeBotData.getLockTimeA(); int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); @@ -729,37 +762,53 @@ public class TradeBot { return; } - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); - Long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - if (estimatedFee == null) { - LOGGER.debug(() -> String.format("Couldn't estimate Bitcoin fees?")); - return; + long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + + // Have we funded P2SH-B already? + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + + BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + + switch (p2shStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + case FUNDED: + break; + + 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, + () -> 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, + () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); + return; } - Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, P2SH_B_OUTPUT_AMOUNT + estimatedFee); - if (p2shFundingTransaction == null) { - LOGGER.warn(() -> String.format("Unable to build P2SH-B funding transaction - lack of funds?")); - 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*/; - if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { - // We couldn't fund P2SH-B at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?")); - return; + 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 - tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", - tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); - - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, 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)); } /** @@ -771,8 +820,9 @@ public class TradeBot { * 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 */ - private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -782,13 +832,8 @@ public class TradeBot { // If we've passed AT refund timestamp then AT will have finished after auto-refunding if (atData.getIsFinished()) { - tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); return; } @@ -798,47 +843,51 @@ public class TradeBot { // AT yet to process MESSAGE return; - byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + byte[] redeemScriptB = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); int lockTimeA = crossChainTradeData.lockTimeA; - Long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - if (estimatedFee == null) { - LOGGER.debug(() -> String.format("Couldn't estimate Bitcoin fees?")); - return; - } + long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - final long minimumBalance = P2SH_B_OUTPUT_AMOUNT + estimatedFee; - Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < minimumBalance) { - if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-B %s balance %s lower than expected %s", p2shAddress, BTC.format(balance), BTC.format(minimumBalance))); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; - return; + BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + + switch (p2shStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-B to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // This shouldn't occur, but defensively bump to next state + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM, + () -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // AT should auto-refund - we don't need to do anything here + return; + + case FUNDED: + break; } // Redeem P2SH-B using secret-B Coin redeemAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. The real funds are in P2SH-A. ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressB); byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivingAccountInfo); + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo); - if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-B at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B redeeming transaction?")); - return; - } + BTC.getInstance().broadcastTransaction(p2shRedeemTransaction); // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT - tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, 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())); } /** @@ -856,8 +905,9 @@ public class TradeBot { * 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 */ - private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -865,29 +915,47 @@ public class TradeBot { } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - - // Refund P2SH-B if we've passed lockTime-B - if (NTP.getTime() >= crossChainTradeData.lockTimeB * 1000L) { - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress)); - notifyStateChange(tradeBotData); + // 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())); return; } - List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); - if (p2shTransactions == null) { - LOGGER.debug(() -> String.format("Unable to fetch transactions relating to %s", p2shAddress)); - return; + byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + + int lockTimeA = crossChainTradeData.lockTimeA; + long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + + BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + + switch (p2shStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + case FUNDED: + case REDEEM_IN_PROGRESS: + // Still waiting for P2SH-B to be funded/redeemed... + return; + + case REDEEMED: + // Bob has redeemed P2SH-B, so double-check that we have redeemed AT... + break; + + case REFUND_IN_PROGRESS: + case REFUNDED: + // We've refunded P2SH-B? Bump to refunding P2SH-A then + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); + return; } - byte[] secretB = BTCP2SH.findP2shSecret(p2shAddress, p2shTransactions); + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddressB); + + byte[] secretB = BTCP2SH.findP2shSecret(p2shAddressB, p2shTransactions); if (secretB == null) // Secret not revealed at this time return; @@ -896,33 +964,29 @@ public class TradeBot { byte[] secretA = tradeBotData.getSecret(); String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); + String messageRecipient = tradeBotData.getAtAddress(); - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); + 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); + messageTransaction.computeNonce(); + messageTransaction.sign(sender); - // Reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // Reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); - return; + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageRecipient, result.name())); + return; + } } - tradeBotData.setState(TradeBotData.State.ALICE_DONE); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - String receivingAddress = tradeBotData.getTradeNativeAddress(); - - LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", - p2shAddress, tradeBotData.getAtAddress(), receivingAddress)); - - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, 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)); } /** @@ -937,8 +1001,9 @@ public class TradeBot { * (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 */ - private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -960,13 +1025,9 @@ public class TradeBot { // We check variable in AT that is set when trade successfully completes if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) { - tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); - notifyStateChange(tradeBotData); + // Not redeemed so must be refunded + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); return; } @@ -979,31 +1040,49 @@ public class TradeBot { // Use secret-A to redeem P2SH-A - byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - - Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); - ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); + byte[] redeemScriptA = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivingAccountInfo); + // 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); - if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-A at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A redeeming transaction?")); - return; + switch (p2shStatus) { + 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: + // Fall-through out of switch... + break; } - tradeBotData.setState(TradeBotData.State.BOB_DONE); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + 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); + + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + BTC.getInstance().broadcastTransaction(p2shRedeemTransaction); + } String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo); - LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_DONE, + () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); } /** @@ -1012,8 +1091,9 @@ public class TradeBot { * 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 */ - private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + 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())); @@ -1025,41 +1105,64 @@ public class TradeBot { if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) return; - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - - Coin refundAmount = Coin.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(p2shAddress); - - - // Determine receive address for refund - String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); - if (receiveAddress == null) { - LOGGER.debug(() -> String.format("Couldn't determine a receive address for P2SH-B refund?")); + // We can't refund P2SH-B until we've passed median block time + int medianBlockTime = BTC.getInstance().getMedianBlockTime(); + if (NTP.getTime() <= medianBlockTime * 1000L) return; + + byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + + int lockTimeA = crossChainTradeData.lockTimeA; + long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + + BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + + switch (p2shStatusB) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-B to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // We must be very close to trade timeout. Defensively try to refund P2SH-A + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED: + break; } - Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); + 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); - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB, receiving.getHash()); - if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { - // We couldn't refund P2SH-B at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?")); - return; + // 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); } - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); - - LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); - notifyStateChange(tradeBotData); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + () -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB)); } - /** Trade-bot is attempting to refund P2SH-A. */ - private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + /** + * Trade-bot is attempting to refund P2SH-A. + * @throws BitcoinException + */ + 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())); @@ -1072,43 +1175,72 @@ public class TradeBot { return; // We can't refund P2SH-A until we've passed median block time - Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); - if (medianBlockTime == null || NTP.getTime() <= medianBlockTime * 1000L) + int medianBlockTime = BTC.getInstance().getMedianBlockTime(); + if (NTP.getTime() <= medianBlockTime * 1000L) return; - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - if (fundingOutputs == null) { - LOGGER.debug(() -> String.format("Couldn't fetch unspent outputs for %s", p2shAddress)); - return; + // 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); + + switch (p2shStatus) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + return; + + case REDEEM_IN_PROGRESS: + case REDEEMED: + // Too late! + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, + () -> String.format("P2SH-A %s already spent!", p2shAddressA)); + return; + + case REFUND_IN_PROGRESS: + case REFUNDED: + break; + + case FUNDED: + // Fall-through out of switch... + break; } - // Determine receive address for refund - String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); - if (receiveAddress == null) { - LOGGER.debug(() -> String.format("Couldn't determine a receive address for P2SH-A refund?")); - return; + 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); } - Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); + updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); + } - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA(), receiving.getHash()); - if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { - // We couldn't refund P2SH-A at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A refund transaction?")); - return; - } - - tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED); + /** 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(); - LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress)); + if (Settings.getInstance().isTradebotSystrayEnabled()) + SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState.name()), 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.name())); + notifyStateChange(tradeBotData); } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index a38ee801..422bcc5b 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -32,7 +32,6 @@ import org.bitcoinj.utils.MonetaryFormat; import org.bitcoinj.wallet.DeterministicKeyChain; import org.bitcoinj.wallet.SendRequest; import org.bitcoinj.wallet.Wallet; -import org.qortal.crosschain.ElectrumX.UnspentOutput; import org.qortal.crypto.Crypto; import org.qortal.settings.Settings; import org.qortal.utils.BitTwiddling; @@ -45,12 +44,16 @@ public class BTC { 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(); @@ -143,16 +146,18 @@ public class BTC { return p2shAddress.toString(); } - /** Returns median timestamp from latest 11 blocks, in seconds. */ - public Integer getMedianBlockTime() { - Integer height = this.electrumX.getCurrentHeight(); - if (height == null) - return null; + /** + * 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 == null || blockHeaders.size() < 11) - return null; + 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()); @@ -166,10 +171,13 @@ public class BTC { /** * Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp. * - * @param timestamp optional milliseconds since epoch - * @return sats per 1000bytes, or null if something went wrong + * @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) { + 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; @@ -177,20 +185,28 @@ public class BTC { return NEW_FEE_AMOUNT; } - public Long getBalance(String base58Address) { - return this.electrumX.getBalance(addressToScript(base58Address)); + /** + * 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)); } - public List getUnspentOutputs(String base58Address) { - List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); - if (unspentOutputs == null) - return null; + /** + * 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 = getOutputs(unspentOutput.hash); - if (transactionOutputs == null) - return null; + List transactionOutputs = this.getOutputs(unspentOutput.hash); unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); } @@ -198,22 +214,64 @@ public class BTC { return unspentTransactionOutputs; } - public List getOutputs(byte[] txHash) { + /** + * 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); - if (rawTransactionBytes == null) - return null; + // XXX bitcoinj: replace with getTransaction() below Transaction transaction = new Transaction(this.params, rawTransactionBytes); return transaction.getOutputs(); } - /** Returns list of raw transactions spending passed address. */ - public List getAddressTransactions(String base58Address) { - return this.electrumX.getAddressTransactions(addressToScript(base58Address)); + /** + * 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); } - public boolean broadcastTransaction(Transaction transaction) { - return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); + /** + * 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()); } /** @@ -226,7 +284,7 @@ public class BTC { */ public Transaction buildSpend(String xprv58, String recipient, long amount) { Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); - wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT)); + 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)); @@ -264,9 +322,10 @@ public class BTC { * Returns first unused receive address given 'm' BIP32 key. * * @param xprv58 BIP32 extended Bitcoin private key - * @return Bitcoin P2PKH address, or null if something went wrong + * @return Bitcoin P2PKH address + * @throws BitcoinException if something went wrong */ - public String getUnusedReceiveAddress(String xprv58) { + public String getUnusedReceiveAddress(String xprv58) throws BitcoinException { Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); @@ -290,9 +349,7 @@ public class BTC { Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List unspentOutputs = this.electrumX.getUnspentOutputs(script); - if (unspentOutputs == null) - return null; + List unspentOutputs = this.electrumX.getUnspentOutputs(script, false); /* * If there are no unspent outputs then either: @@ -310,9 +367,7 @@ public class BTC { } // Ask for transaction history - if it's empty then key has never been used - List historicTransactionHashes = this.electrumX.getAddressTransactions(script); - if (historicTransactionHashes == null) - return null; + List historicTransactionHashes = this.electrumX.getAddressTransactions(script, false); if (!historicTransactionHashes.isEmpty()) { // Fully spent key - case (a) @@ -383,9 +438,12 @@ public class BTC { Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List unspentOutputs = btc.electrumX.getUnspentOutputs(script); - if (unspentOutputs == null) + 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: @@ -404,10 +462,12 @@ public class BTC { } // Ask for transaction history - if it's empty then key has never been used - List historicTransactionHashes = btc.electrumX.getAddressTransactions(script); - if (historicTransactionHashes == null) - throw new UTXOProviderException( - String.format("Unable to fetch transaction history for %s", address)); + 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) @@ -427,10 +487,13 @@ public class BTC { areAllKeysSpent = false; for (UnspentOutput unspentOutput : unspentOutputs) { - List transactionOutputs = btc.getOutputs(unspentOutput.hash); - if (transactionOutputs == null) + 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); @@ -463,11 +526,11 @@ public class BTC { } public int getChainHeadHeight() throws UTXOProviderException { - Integer height = btc.electrumX.getCurrentHeight(); - if (height == null) + try { + return btc.electrumX.getCurrentHeight(); + } catch (BitcoinException e) { throw new UTXOProviderException("Unable to determine Bitcoin chain height"); - - return height.intValue(); + } } public NetworkParameters getParams() { diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 1adacfb8..f3db8587 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -874,7 +874,8 @@ public class BTCACCT { String atAddress = crossChainTradeData.qortalAtAddress; String redeemerAddress = crossChainTradeData.qortalPartnerAddress; - List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null); + // We don't have partner's public key so we check every message to AT + List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, atAddress, null, null, null); if (messageTransactionsData == null) return null; diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java index 4bd175f2..ef59ee4d 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -1,9 +1,15 @@ package org.qortal.crosschain; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.function.Function; import org.bitcoinj.core.Address; +import org.bitcoinj.core.Base58; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; @@ -25,6 +31,10 @@ import com.google.common.primitives.Bytes; public class BTCP2SH { + public enum Status { + UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED + } + public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; @@ -234,4 +244,129 @@ public class BTCP2SH { 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(); + + List transactionHashes = btc.getAddressTransactions(p2shAddress, BTC.INCLUDE_UNCONFIRMED); + + // Sort by confirmed first, followed by ascending height + transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight)); + + // Transaction cache + Map transactionsByHash = new HashMap<>(); + // HASH160(redeem script) for this p2shAddress + byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress); + + // Check for spends first, caching full transaction info as we progress just in case we don't return in this loop + for (TransactionHash transactionInfo : transactionHashes) { + BitcoinTransaction bitcoinTransaction = btc.getTransaction(transactionInfo.txHash); + + // Cache for possible later reuse + transactionsByHash.put(transactionInfo.txHash, bitcoinTransaction); + + // Acceptable funding is one transaction output, so we're expecting only one input + if (bitcoinTransaction.inputs.size() != 1) + // Wrong number of inputs + continue; + + String scriptSig = bitcoinTransaction.inputs.get(0).scriptSig; + + List scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes()); + if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4) + // Not spending one of these P2SH + 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 + 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); + } + + String ourScriptPubKey = HashCode.fromBytes(addressToScriptPubKey(p2shAddress)).toString(); + + // Check for funding + for (TransactionHash transactionInfo : transactionHashes) { + BitcoinTransaction bitcoinTransaction = transactionsByHash.get(transactionInfo.txHash); + if (bitcoinTransaction == null) + // Should be present in map! + throw new BitcoinException("Cached Bitcoin transaction now missing?"); + + // Check outputs for our specific P2SH + for (BitcoinTransaction.Output output : bitcoinTransaction.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)) + // Not funding our specific P2SH + continue; + + return transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED; + } + } + + return Status.UNFUNDED; + } + + private static List extractScriptSigChunks(byte[] scriptSigBytes) { + List chunks = new ArrayList<>(); + + int offset = 0; + int previousOffset = 0; + while (offset < scriptSigBytes.length) { + byte pushOp = scriptSigBytes[offset++]; + + if (pushOp < 0 || pushOp > 0x4c) + // Unacceptable OP + return Collections.emptyList(); + + // Special treatment for OP_PUSHDATA1 + if (pushOp == 0x4c) { + if (offset >= scriptSigBytes.length) + // Run out of scriptSig bytes? + return Collections.emptyList(); + + pushOp = scriptSigBytes[offset++]; + } + + previousOffset = offset; + offset += Byte.toUnsignedInt(pushOp); + + byte[] chunk = Arrays.copyOfRange(scriptSigBytes, previousOffset, offset); + chunks.add(chunk); + } + + return chunks; + } + + private static byte[] addressToScriptPubKey(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + byte[] scriptPubKey = new byte[1 + 1 + 20 + 1]; + scriptPubKey[0x00] = (byte) 0xa9; /* OP_HASH160 */ + scriptPubKey[0x01] = (byte) 0x14; /* PUSH 0x14 bytes */ + System.arraycopy(p2shAddressBytes, 1, scriptPubKey, 2, 0x14); + scriptPubKey[0x16] = (byte) 0x87; /* OP_EQUAL */ + + return scriptPubKey; + } + + private static byte[] addressToRedeemScriptHash(String p2shAddress) { + // We want the HASH160 part of the P2SH address + byte[] p2shAddressBytes = Base58.decode(p2shAddress); + + return Arrays.copyOfRange(p2shAddressBytes, 1, 1 + 20); + } + } diff --git a/src/main/java/org/qortal/crosschain/BitcoinException.java b/src/main/java/org/qortal/crosschain/BitcoinException.java new file mode 100644 index 00000000..01db9d49 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinException.java @@ -0,0 +1,57 @@ +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 new file mode 100644 index 00000000..0e22e27a --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java @@ -0,0 +1,31 @@ +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/BitcoinTransaction.java b/src/main/java/org/qortal/crosschain/BitcoinTransaction.java new file mode 100644 index 00000000..05516bc4 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinTransaction.java @@ -0,0 +1,70 @@ +package org.qortal.crosschain; + +import java.util.List; +import java.util.stream.Collectors; + +public class BitcoinTransaction { + + public final String txHash; + public final int size; + public final int locktime; + // Not present if transaction is unconfirmed + public final Integer timestamp; + + public static class Input { + public final String scriptSig; + public final int sequence; + public final String outputTxHash; + public final int outputVout; + + public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) { + this.scriptSig = scriptSig; + this.sequence = sequence; + this.outputTxHash = outputTxHash; + this.outputVout = outputVout; + } + + public String toString() { + return String.format("{output %s:%d, sequence %d, scriptSig %s}", + this.outputTxHash, this.outputVout, this.sequence, this.scriptSig); + } + } + public final List inputs; + + public static class Output { + public final String scriptPubKey; + public final long value; + + public Output(String scriptPubKey, long value) { + this.scriptPubKey = scriptPubKey; + this.value = value; + } + + public String toString() { + return String.format("{value %d, scriptPubKey %s}", this.value, this.scriptPubKey); + } + } + public final List outputs; + + public BitcoinTransaction(String txHash, int size, int locktime, Integer timestamp, + List inputs, List outputs) { + this.txHash = txHash; + this.size = size; + this.locktime = locktime; + this.timestamp = timestamp; + this.inputs = inputs; + this.outputs = outputs; + } + + public String toString() { + return String.format("txHash %s, size %d, locktime %d, timestamp %d\n" + + "\tinputs: [%s]\n" + + "\toutputs: [%s]\n", + this.txHash, + this.size, + this.locktime, + this.timestamp, + this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")), + this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t"))); + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index ffe396f3..8e6d07a0 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -14,8 +14,9 @@ import java.util.NoSuchElementException; import java.util.Random; import java.util.Scanner; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; -import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import org.apache.logging.log4j.LogManager; @@ -35,17 +36,27 @@ public class ElectrumX { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); private static final Random RANDOM = new Random(); + private static final double MIN_PROTOCOL_VERSION = 1.2; + private static final int DEFAULT_TCP_PORT = 50001; private static final int DEFAULT_SSL_PORT = 50002; private static final int BLOCK_HEADER_LENGTH = 80; + private static final String MAIN_GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + private static final String TEST3_GENESIS_HASH = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943"; + // We won't know REGTEST (i.e. local) genesis block hash + + // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" + private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content + + // Key: Bitcoin network (e.g. "MAIN", "TEST3", "REGTEST"), value: ElectrumX instance private static final Map instances = new HashMap<>(); - static class Server { + private static class Server { String hostname; - enum ConnectionType { TCP, SSL }; + enum ConnectionType { TCP, SSL } ConnectionType connectionType; int port; @@ -82,7 +93,9 @@ public class ElectrumX { } } private Set servers = new HashSet<>(); + private List remainingServers = new ArrayList<>(); + private String expectedGenesisHash; private Server currentServer; private Socket socket; private Scanner scanner; @@ -93,7 +106,9 @@ public class ElectrumX { private ElectrumX(String bitcoinNetwork) { switch (bitcoinNetwork) { case "MAIN": - servers.addAll(Arrays.asList( + this.expectedGenesisHash = MAIN_GENESIS_HASH; + + this.servers.addAll(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), @@ -127,16 +142,23 @@ public class ElectrumX { break; case "TEST3": - servers.addAll(Arrays.asList( - new Server("tn.not.fyi", Server.ConnectionType.TCP, 55001), - new Server("tn.not.fyi", Server.ConnectionType.SSL, 55002), + this.expectedGenesisHash = TEST3_GENESIS_HASH; + + this.servers.addAll(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))); break; case "REGTEST": - servers.addAll(Arrays.asList( + this.expectedGenesisHash = null; + + this.servers.addAll(Arrays.asList( new Server("localhost", Server.ConnectionType.TCP, DEFAULT_TCP_PORT), new Server("localhost", Server.ConnectionType.SSL, DEFAULT_SSL_PORT))); break; @@ -146,7 +168,6 @@ public class ElectrumX { } LOGGER.debug(() -> String.format("Starting ElectrumX support for %s Bitcoin network", bitcoinNetwork)); - rpc("server.banner"); } /** Returns ElectrumX instance linked to passed Bitcoin network, one of "MAIN", "TEST3" or "REGTEST". */ @@ -159,35 +180,50 @@ public class ElectrumX { // Methods for use by other classes - public Integer getCurrentHeight() { + /** + * Returns current blockchain height. + *

    + * @throws BitcoinException if error occurs + */ + public int getCurrentHeight() throws BitcoinException { Object blockObj = this.rpc("blockchain.headers.subscribe"); if (!(blockObj instanceof JSONObject)) - return null; + throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.headers.subscribe RPC"); JSONObject blockJson = (JSONObject) blockObj; - if (!blockJson.containsKey("height")) - return null; + Object heightObj = blockJson.get("height"); - return ((Long) blockJson.get("height")).intValue(); + if (!(heightObj instanceof Long)) + throw new BitcoinException.NetworkException("Missing/invalid 'height' in JSON from ElectrumX blockchain.headers.subscribe RPC"); + + return ((Long) heightObj).intValue(); } - public List getBlockHeaders(int startHeight, long count) { + /** + * Returns list of raw block headers, starting from startHeight inclusive. + *

    + * @throws BitcoinException if error occurs + */ + public List getBlockHeaders(int startHeight, long count) throws BitcoinException { Object blockObj = this.rpc("blockchain.block.headers", startHeight, count); if (!(blockObj instanceof JSONObject)) - return null; + throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.block.headers RPC"); JSONObject blockJson = (JSONObject) blockObj; - if (!blockJson.containsKey("count") || !blockJson.containsKey("hex")) - return null; + Object countObj = blockJson.get("count"); + Object hexObj = blockJson.get("hex"); - Long returnedCount = (Long) blockJson.get("count"); - String hex = (String) blockJson.get("hex"); + if (!(countObj instanceof Long) || !(hexObj instanceof String)) + throw new BitcoinException.NetworkException("Missing/invalid 'count' or 'hex' entries in JSON from ElectrumX blockchain.block.headers RPC"); + + Long returnedCount = (Long) countObj; + String hex = (String) hexObj; byte[] raw = HashCode.fromString(hex).asBytes(); if (raw.length != returnedCount * BLOCK_HEADER_LENGTH) - return null; + throw new BitcoinException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC"); List rawBlockHeaders = new ArrayList<>(returnedCount.intValue()); for (int i = 0; i < returnedCount; ++i) @@ -196,46 +232,43 @@ public class ElectrumX { return rawBlockHeaders; } - /** Returns confirmed balance, based on passed payment script, or null if there was an error or no known balance. */ - public Long getBalance(byte[] script) { + /** + * 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(byte[] script) throws BitcoinException { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); if (!(balanceObj instanceof JSONObject)) - return null; + throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.scripthash.get_balance RPC"); JSONObject balanceJson = (JSONObject) balanceObj; - if (!balanceJson.containsKey("confirmed")) - return null; + Object confirmedBalanceObj = balanceJson.get("confirmed"); + + if (!(confirmedBalanceObj instanceof Long)) + throw new BitcoinException.NetworkException("Missing confirmed balance from ElectrumX blockchain.scripthash.get_balance RPC"); return (Long) balanceJson.get("confirmed"); } - /** Unspent output info as returned by ElectrumX network. */ - public static class UnspentOutput { - public final byte[] hash; - public final int index; - public final int height; - public final long value; - - public UnspentOutput(byte[] hash, int index, int height, long value) { - this.hash = hash; - this.index = index; - this.height = height; - this.value = value; - } - } - - /** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */ - public List getUnspentOutputs(byte[] script) { + /** + * Returns list of unspent outputs pertaining to passed payment script. + *

    + * @return list of unspent outputs, or empty list if script unknown + * @throws BitcoinException if there was an error. + */ + public List getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws BitcoinException { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); if (!(unspentJson instanceof JSONArray)) - return null; + throw new BitcoinException("Expected array output from ElectrumX blockchain.scripthash.listunspent RPC"); List unspentOutputs = new ArrayList<>(); for (Object rawUnspent : (JSONArray) unspentJson) { @@ -243,7 +276,7 @@ public class ElectrumX { int height = ((Long) unspent.get("height")).intValue(); // We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0) - if (height <= 0) + if (!includeUnconfirmed && height <= 0) continue; byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); @@ -256,68 +289,163 @@ public class ElectrumX { return unspentOutputs; } - /** Returns raw transaction for passed transaction hash, or null if not found. */ - public byte[] getRawTransaction(byte[] txHash) { - Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); + /** + * Returns raw transaction for passed transaction hash. + *

    + * @throws BitcoinException.NotFoundException if transaction not found + * @throws BitcoinException if error occurs + */ + public byte[] getRawTransaction(byte[] txHash) throws BitcoinException { + Object rawTransactionHex; + try { + rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); + } catch (BitcoinException.NetworkException e) { + // DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'}) + if (Integer.valueOf(-5).equals(e.getDaemonErrorCode())) + throw new BitcoinException.NotFoundException(e.getMessage()); + + throw e; + } + if (!(rawTransactionHex instanceof String)) - return null; + throw new BitcoinException.NetworkException("Expected hex string as raw transaction from ElectrumX blockchain.transaction.get RPC"); return HashCode.fromString((String) rawTransactionHex).asBytes(); } - /** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */ - public List getAddressTransactions(byte[] script) { + /** + * Returns transaction info for passed transaction hash. + *

    + * @throws BitcoinException.NotFoundException if transaction not found + * @throws BitcoinException if error occurs + */ + public BitcoinTransaction getTransaction(String txHash) throws BitcoinException { + Object transactionObj; + try { + transactionObj = this.rpc("blockchain.transaction.get", txHash, true); + } catch (BitcoinException.NetworkException e) { + // DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'}) + if (Integer.valueOf(-5).equals(e.getDaemonErrorCode())) + throw new BitcoinException.NotFoundException(e.getMessage()); + + throw e; + } + + if (!(transactionObj instanceof JSONObject)) + throw new BitcoinException.NetworkException("Expected JSONObject as response from ElectrumX blockchain.transaction.get RPC"); + + JSONObject transactionJson = (JSONObject) transactionObj; + + Object inputsObj = transactionJson.get("vin"); + if (!(inputsObj instanceof JSONArray)) + throw new BitcoinException.NetworkException("Expected JSONArray for 'vin' from ElectrumX blockchain.transaction.get RPC"); + + Object outputsObj = transactionJson.get("vout"); + if (!(outputsObj instanceof JSONArray)) + throw new BitcoinException.NetworkException("Expected JSONArray for 'vout' from ElectrumX blockchain.transaction.get RPC"); + + try { + int size = ((Long) transactionJson.get("size")).intValue(); + int locktime = ((Long) transactionJson.get("locktime")).intValue(); + + // Timestamp might not be present, e.g. for unconfirmed transaction + Object timeObj = transactionJson.get("time"); + Integer timestamp = timeObj != null + ? ((Long) timeObj).intValue() + : null; + + List inputs = new ArrayList<>(); + for (Object inputObj : (JSONArray) inputsObj) { + JSONObject inputJson = (JSONObject) inputObj; + + String scriptSig = (String) ((JSONObject) inputJson.get("scriptSig")).get("hex"); + int sequence = ((Long) inputJson.get("sequence")).intValue(); + String outputTxHash = (String) inputJson.get("txid"); + int outputVout = ((Long) inputJson.get("vout")).intValue(); + + inputs.add(new BitcoinTransaction.Input(scriptSig, sequence, outputTxHash, outputVout)); + } + + List outputs = new ArrayList<>(); + for (Object outputObj : (JSONArray) outputsObj) { + JSONObject outputJson = (JSONObject) outputObj; + + String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex"); + long value = (long) (((Double) outputJson.get("value")) * 1e8); + + outputs.add(new BitcoinTransaction.Output(scriptPubKey, value)); + } + + return new BitcoinTransaction(txHash, size, locktime, timestamp, inputs, outputs); + } catch (NullPointerException | ClassCastException e) { + // Unexpected / invalid response from ElectrumX server + } + + throw new BitcoinException.NetworkException("Unexpected JSON format from ElectrumX blockchain.transaction.get RPC"); + } + + /** + * Returns list of transactions, relating to passed payment script. + *

    + * @return list of related transactions, or empty list if script unknown + * @throws BitcoinException if error occurs + */ + public List getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws BitcoinException { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); if (!(transactionsJson instanceof JSONArray)) - return null; + throw new BitcoinException.NetworkException("Expected array output from ElectrumX blockchain.scripthash.get_history RPC"); - List rawTransactions = new ArrayList<>(); + List transactionHashes = new ArrayList<>(); for (Object rawTransactionInfo : (JSONArray) transactionsJson) { JSONObject transactionInfo = (JSONObject) rawTransactionInfo; - // We only want confirmed transactions - if (!transactionInfo.containsKey("height")) + Long height = (Long) transactionInfo.get("height"); + if (!includeUnconfirmed && (height == null || height == 0)) + // We only want confirmed transactions continue; String txHash = (String) transactionInfo.get("tx_hash"); - String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", txHash); - if (rawTransactionHex == null) - return null; - rawTransactions.add(HashCode.fromString(rawTransactionHex).asBytes()); + transactionHashes.add(new TransactionHash(height.intValue(), txHash)); } - return rawTransactions; + return transactionHashes; } - /** Returns true if raw transaction successfully broadcast. */ - public boolean broadcastTransaction(byte[] transactionBytes) { + /** + * Broadcasts raw transaction to Bitcoin network. + *

    + * @throws BitcoinException if error occurs + */ + public void broadcastTransaction(byte[] transactionBytes) throws BitcoinException { Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString()); - if (rawBroadcastResult == null) - return false; - // If result is a String, then it is simply transaction hash. - // Otherwise result is JSON and probably contains error info instead. - return rawBroadcastResult instanceof String; + // We're expecting a simple string that is the transaction hash + if (!(rawBroadcastResult instanceof String)) + throw new BitcoinException.NetworkException("Unexpected response from ElectrumX blockchain.transaction.broadcast RPC"); } // Class-private utility methods - /** Query current server for its list of peer servers, and return those we can parse. */ - private Set serverPeersSubscribe() { + /** + * Query current server for its list of peer servers, and return those we can parse. + *

    + * @throws BitcoinException + * @throws ClassCastException to be handled by caller + */ + private Set serverPeersSubscribe() throws BitcoinException { Set newServers = new HashSet<>(); Object peers = this.connectedRpc("server.peers.subscribe"); - if (!(peers instanceof JSONArray)) - return newServers; for (Object rawPeer : (JSONArray) peers) { JSONArray peer = (JSONArray) rawPeer; if (peer.size() < 3) + // We're expecting at least 3 fields for each peer entry: IP, hostname, features continue; String hostname = (String) peer.get(1); @@ -338,9 +466,14 @@ public class ElectrumX { connectionType = Server.ConnectionType.TCP; port = DEFAULT_TCP_PORT; break; + + default: + // e.g. could be 'v' for protocol version, or 'p' for pruning limit + break; } if (connectionType == null) + // We couldn't extract any peer connection info? continue; // Possible non-default port? @@ -360,8 +493,16 @@ public class ElectrumX { return newServers; } - /** Return output from RPC call, with automatic reconnection to different server if needed. */ - private synchronized Object rpc(String method, Object...params) { + /** + * Performs RPC call, with automatic reconnection to different server if needed. + *

    + * @return "result" object from within JSON output + * @throws BitcoinException if server returns error or something goes wrong + */ + private synchronized Object rpc(String method, Object...params) throws BitcoinException { + if (this.remainingServers.isEmpty()) + this.remainingServers.addAll(this.servers); + while (haveConnection()) { Object response = connectedRpc(method, params); if (response != null) @@ -376,18 +517,17 @@ public class ElectrumX { this.scanner = null; } - return null; + // Failed to perform RPC - maybe lack of servers? + throw new BitcoinException.NetworkException("Failed to perform Bitcoin RPC"); } /** Returns true if we have, or create, a connection to an ElectrumX server. */ - private boolean haveConnection() { + private boolean haveConnection() throws BitcoinException { if (this.currentServer != null) return true; - List remainingServers = new ArrayList<>(this.servers); - - while (!remainingServers.isEmpty()) { - Server server = remainingServers.remove(RANDOM.nextInt(remainingServers.size())); + while (!this.remainingServers.isEmpty()) { + Server server = this.remainingServers.remove(RANDOM.nextInt(this.remainingServers.size())); LOGGER.trace(() -> String.format("Connecting to %s", server)); try { @@ -400,23 +540,41 @@ public class ElectrumX { if (server.connectionType == Server.ConnectionType.SSL) { SSLSocketFactory factory = TrustlessSSLSocketFactory.getSocketFactory(); - this.socket = (SSLSocket) factory.createSocket(this.socket, server.hostname, server.port, true); + this.socket = factory.createSocket(this.socket, server.hostname, server.port, true); } this.scanner = new Scanner(this.socket.getInputStream()); this.scanner.useDelimiter("\n"); - // Check connection works by asking for more servers + // Check connection is suitable by asking for server features, including genesis block hash + JSONObject featuresJson = (JSONObject) this.connectedRpc("server.features"); + + if (featuresJson == null || Double.valueOf((String) featuresJson.get("protocol_min")) < MIN_PROTOCOL_VERSION) + continue; + + if (this.expectedGenesisHash != null && !((String) featuresJson.get("genesis_hash")).equals(this.expectedGenesisHash)) + continue; + + // Ask for more servers Set moreServers = serverPeersSubscribe(); + // Discard duplicate servers we already know moreServers.removeAll(this.servers); - remainingServers.addAll(moreServers); + // Add to both lists + this.remainingServers.addAll(moreServers); this.servers.addAll(moreServers); LOGGER.debug(() -> String.format("Connected to %s", server)); this.currentServer = server; return true; - } catch (IOException e) { + } catch (IOException | BitcoinException | ClassCastException | NullPointerException e) { // Try another server... + if (this.socket != null && !this.socket.isClosed()) + try { + this.socket.close(); + } catch (IOException e1) { + // We did try... + } + this.socket = null; this.scanner = null; } @@ -425,11 +583,20 @@ public class ElectrumX { return false; } + /** + * Perform RPC using currently connected server. + *

    + * @param method + * @param params + * @return response Object, or null if server fails to respond + * @throws BitcoinException if server returns error + */ @SuppressWarnings("unchecked") - private Object connectedRpc(String method, Object...params) { + private Object connectedRpc(String method, Object...params) throws BitcoinException { JSONObject requestJson = new JSONObject(); requestJson.put("id", this.nextId++); requestJson.put("method", method); + requestJson.put("jsonrpc", "2.0"); JSONArray requestParams = new JSONArray(); requestParams.addAll(Arrays.asList(params)); @@ -444,20 +611,52 @@ public class ElectrumX { this.socket.getOutputStream().write(request.getBytes()); response = scanner.next(); } catch (IOException | NoSuchElementException e) { + // Unable to send, or receive -- try another server? return null; } LOGGER.trace(() -> String.format("Response: %s", response)); if (response.isEmpty()) + // Empty response - try another server? return null; Object responseObj = JSONValue.parse(response); if (!(responseObj instanceof JSONObject)) + // Unexpected response - try another server? return null; JSONObject responseJson = (JSONObject) responseObj; + Object errorObj = responseJson.get("error"); + if (errorObj != null) { + if (!(errorObj instanceof JSONObject)) + throw new BitcoinException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method)); + + JSONObject errorJson = (JSONObject) errorObj; + + Object messageObj = errorJson.get("message"); + + if (!(messageObj instanceof String)) + throw new BitcoinException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method)); + + String message = (String) messageObj; + + // Some error 'messages' are actually wrapped upstream bitcoind errors: + // "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})" + // We want to detect these and extract the upstream error code for caller's use + Matcher messageMatcher = DAEMON_ERROR_REGEX.matcher(message); + if (messageMatcher.find()) + try { + int daemonErrorCode = Integer.parseInt(messageMatcher.group(1)); + throw new BitcoinException.NetworkException(daemonErrorCode, message); + } catch (NumberFormatException e) { + // We couldn't parse the error code integer? Fall-through to generic exception... + } + + throw new BitcoinException.NetworkException(message); + } + return responseJson.get("result"); } diff --git a/src/main/java/org/qortal/crosschain/TransactionHash.java b/src/main/java/org/qortal/crosschain/TransactionHash.java new file mode 100644 index 00000000..c002ae80 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/TransactionHash.java @@ -0,0 +1,31 @@ +package org.qortal.crosschain; + +import java.util.Comparator; + +public class TransactionHash { + + public static final Comparator CONFIRMED_FIRST = (a, b) -> Boolean.compare(a.height != 0, b.height != 0); + + public final int height; + public final String txHash; + + public TransactionHash(int height, String txHash) { + this.height = height; + this.txHash = txHash; + } + + public int getHeight() { + return this.height; + } + + public String getTxHash() { + return this.txHash; + } + + public String toString() { + return this.height == 0 + ? String.format("txHash %s (unconfirmed)", this.txHash) + : String.format("txHash %s (height %d)", this.txHash, this.height); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/UnspentOutput.java b/src/main/java/org/qortal/crosschain/UnspentOutput.java new file mode 100644 index 00000000..86aa533d --- /dev/null +++ b/src/main/java/org/qortal/crosschain/UnspentOutput.java @@ -0,0 +1,16 @@ +package org.qortal.crosschain; + +/** Unspent output info as returned by ElectrumX network. */ +public class UnspentOutput { + public final byte[] hash; + public final int index; + public final int height; + public final long value; + + public UnspentOutput(byte[] hash, int index, int height, long value) { + this.hash = hash; + this.index = index; + this.height = height; + this.value = value; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crypto/MemoryPoW.java b/src/main/java/org/qortal/crypto/MemoryPoW.java index 82e88248..01f4f6fd 100644 --- a/src/main/java/org/qortal/crypto/MemoryPoW.java +++ b/src/main/java/org/qortal/crypto/MemoryPoW.java @@ -29,6 +29,10 @@ public class MemoryPoW { do { ++nonce; + // If we've been interrupted, exit fast with invalid value + if (Thread.currentThread().isInterrupted()) + return -1; + seed *= seedMultiplier; // per nonce state[0] = longHash[0] ^ seed; diff --git a/src/main/java/org/qortal/data/block/BlockData.java b/src/main/java/org/qortal/data/block/BlockData.java index 63f40a47..3567d0f8 100644 --- a/src/main/java/org/qortal/data/block/BlockData.java +++ b/src/main/java/org/qortal/data/block/BlockData.java @@ -79,6 +79,25 @@ public class BlockData implements Serializable { null, 0, null, null); } + public BlockData(BlockData other) { + this.version = other.version; + this.reference = other.reference; + this.transactionCount = other.transactionCount; + this.totalFees = other.totalFees; + this.transactionsSignature = other.transactionsSignature; + this.height = other.height; + this.timestamp = other.timestamp; + this.minterPublicKey = other.minterPublicKey; + this.minterSignature = other.minterSignature; + this.atCount = other.atCount; + this.atFees = other.atFees; + this.encodedOnlineAccounts = other.encodedOnlineAccounts; + this.onlineAccountsCount = other.onlineAccountsCount; + this.onlineAccountsTimestamp = other.onlineAccountsTimestamp; + this.onlineAccountsSignatures = other.onlineAccountsSignatures; + this.signature = other.signature; + } + // Getters/setters public byte[] getSignature() { diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 0f57845d..5c9cff4b 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -190,4 +190,9 @@ public class TradeBotData { return this.receivingAccountInfo; } + // Mostly for debugging + public String toString() { + return String.format("%s: %s", this.atAddress, this.tradeState.name()); + } + } diff --git a/src/main/java/org/qortal/repository/MessageRepository.java b/src/main/java/org/qortal/repository/MessageRepository.java new file mode 100644 index 00000000..db74f0e6 --- /dev/null +++ b/src/main/java/org/qortal/repository/MessageRepository.java @@ -0,0 +1,31 @@ +package org.qortal.repository; + +import java.util.List; + +import org.qortal.data.transaction.MessageTransactionData; + +public interface MessageRepository { + + /** + * Returns list of confirmed MESSAGE transaction data matching (some) participants. + *

    + * At least one of senderPublicKey or recipient must be specified. + *

    + * @throws DataException + */ + public List getMessagesByParticipants(byte[] senderPublicKey, + String recipient, Integer limit, Integer offset, Boolean reverse) throws DataException; + + /** + * Does a MESSAGE exist with matching sender (pubkey), recipient and message payload? + *

    + * Includes both confirmed and unconfirmed transactions! + *

    + * @param senderPublicKey + * @param recipient + * @param messageData + * @return true if a message exists, false otherwise + */ + public boolean exists(byte[] senderPublicKey, String recipient, byte[] messageData) throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 71936373..cc3a5336 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -18,6 +18,8 @@ public interface Repository extends AutoCloseable { public GroupRepository getGroupRepository(); + public MessageRepository getMessageRepository(); + public NameRepository getNameRepository(); public NetworkRepository getNetworkRepository(); diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 0cb97f9e..68d0cdac 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -6,7 +6,6 @@ import java.util.Map; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.data.group.GroupApprovalData; import org.qortal.data.transaction.GroupApprovalTransactionData; -import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.data.transaction.TransferAssetTransactionData; import org.qortal.transaction.Transaction.TransactionType; @@ -124,18 +123,6 @@ public interface TransactionRepository { */ public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException; - /** - * Returns list of MESSAGE transaction data matching recipient. - * @param recipient - * @param limit - * @param offset - * @param reverse - * @return - * @throws DataException - */ - public List getMessagesByRecipient(String recipient, - Integer limit, Integer offset, Boolean reverse) throws DataException; - /** * Returns list of transactions relating to specific asset ID. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index a18bbe7a..6cf12862 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -650,6 +650,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CHECKPOINT"); break; + case 23: + // MESSAGE transactions index + stmt.execute("CREATE INDEX IF NOT EXISTS MessageTransactionsRecipientIndex ON MessageTransactions (recipient, sender)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java new file mode 100644 index 00000000..f00c79fc --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBMessageRepository.java @@ -0,0 +1,85 @@ +package org.qortal.repository.hsqldb; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.MessageRepository; +import org.qortal.transaction.Transaction.TransactionType; + +public class HSQLDBMessageRepository implements MessageRepository { + + protected HSQLDBRepository repository; + + public HSQLDBMessageRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + @Override + public List getMessagesByParticipants(byte[] senderPublicKey, + String recipient, Integer limit, Integer offset, Boolean reverse) throws DataException { + if (senderPublicKey == null && recipient == null) + throw new DataException("At least one of senderPublicKey or recipient required to fetch matching messages"); + + StringBuilder sql = new StringBuilder(1024); + sql.append("SELECT signature from MessageTransactions " + + "JOIN Transactions USING (signature) " + + "JOIN BlockTransactions ON transaction_signature = signature " + + "WHERE "); + + List whereClauses = new ArrayList<>(); + List bindParams = new ArrayList<>(); + + if (senderPublicKey != null) { + whereClauses.add("sender = ?"); + bindParams.add(senderPublicKey); + } + + if (recipient != null) { + whereClauses.add("recipient = ?"); + bindParams.add(recipient); + } + + sql.append(String.join(" AND ", whereClauses)); + + sql.append("ORDER BY Transactions.created_when"); + sql.append((reverse == null || !reverse) ? " ASC" : " DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List messageTransactionsData = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { + if (resultSet == null) + return messageTransactionsData; + + do { + byte[] signature = resultSet.getBytes(1); + + TransactionData transactionData = this.repository.getTransactionRepository().fromSignature(signature); + if (transactionData == null || transactionData.getType() != TransactionType.MESSAGE) + throw new DataException("Inconsistent data from repository when fetching message"); + + messageTransactionsData.add((MessageTransactionData) transactionData); + } while (resultSet.next()); + + return messageTransactionsData; + } catch (SQLException e) { + throw new DataException("Unable to fetch matching messages from repository", e); + } + } + + @Override + public boolean exists(byte[] senderPublicKey, String recipient, byte[] messageData) throws DataException { + try { + return this.repository.exists("MessageTransactions", "sender = ? AND recipient = ? AND data = ?", senderPublicKey, recipient, messageData); + } catch (SQLException e) { + throw new DataException("Unable to check for existing message in repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 5f527540..2ef5b225 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -38,6 +38,7 @@ import org.qortal.repository.ChatRepository; import org.qortal.repository.CrossChainRepository; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; +import org.qortal.repository.MessageRepository; import org.qortal.repository.NameRepository; import org.qortal.repository.NetworkRepository; import org.qortal.repository.Repository; @@ -129,6 +130,11 @@ public class HSQLDBRepository implements Repository { return new HSQLDBGroupRepository(this); } + @Override + public MessageRepository getMessageRepository() { + return new HSQLDBMessageRepository(this); + } + @Override public NameRepository getNameRepository() { return new HSQLDBNameRepository(this); diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 359940a7..a603a916 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -19,7 +19,6 @@ import org.qortal.data.PaymentData; import org.qortal.data.group.GroupApprovalData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.GroupApprovalTransactionData; -import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; import org.qortal.data.transaction.TransferAssetTransactionData; import org.qortal.repository.DataException; @@ -694,43 +693,6 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } - @Override - public List getMessagesByRecipient(String recipient, - Integer limit, Integer offset, Boolean reverse) throws DataException { - StringBuilder sql = new StringBuilder(1024); - sql.append("SELECT signature from MessageTransactions " - + "JOIN Transactions USING (signature) " - + "JOIN BlockTransactions ON transaction_signature = signature " - + "WHERE recipient = ?"); - - sql.append("ORDER BY Transactions.created_when"); - sql.append((reverse == null || !reverse) ? " ASC" : " DESC"); - - HSQLDBRepository.limitOffsetSql(sql, limit, offset); - - List messageTransactionsData = new ArrayList<>(); - - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), recipient)) { - if (resultSet == null) - return messageTransactionsData; - - do { - byte[] signature = resultSet.getBytes(1); - - TransactionData transactionData = this.fromSignature(signature); - if (transactionData == null || transactionData.getType() != TransactionType.MESSAGE) - return null; - - messageTransactionsData.add((MessageTransactionData) transactionData); - } while (resultSet.next()); - - return messageTransactionsData; - } catch (SQLException e) { - throw new DataException("Unable to fetch trade-bot messages from repository", e); - } - } - - @Override public List getAssetTransactions(long assetId, ConfirmationStatus confirmationStatus, Integer limit, Integer offset, Boolean reverse) throws DataException { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 69e66b8d..b42675c5 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -98,6 +98,9 @@ public class Settings { // Which blockchains this node is running private String blockchainConfig = null; // use default from resources private BitcoinNet bitcoinNet = BitcoinNet.MAIN; + // Also crosschain-related: + /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ + private boolean tradebotSystrayEnabled = false; // Repository related /** Queries that take longer than this are logged. (milliseconds) */ @@ -367,6 +370,10 @@ public class Settings { return this.bitcoinNet; } + public boolean isTradebotSystrayEnabled() { + return this.tradebotSystrayEnabled; + } + public Long getSlowQueryThreshold() { return this.slowQueryThreshold; } diff --git a/src/test/java/org/qortal/test/BlockTests.java b/src/test/java/org/qortal/test/BlockTests.java index 285e6d81..3e3d0ada 100644 --- a/src/test/java/org/qortal/test/BlockTests.java +++ b/src/test/java/org/qortal/test/BlockTests.java @@ -133,4 +133,48 @@ public class BlockTests extends Common { } } + @Test + public void testCommonBlockSearch() { + // Given a list of block summaries, trim all trailing summaries after common block + + // We'll represent known block summaries as a list of booleans, + // where the boolean value indicates whether peer's block is also in our repository. + + // Trivial case, single element array + assertCommonBlock(0, new boolean[] { true }); + + // Test odd and even array lengths + for (int arrayLength = 5; arrayLength <= 6; ++arrayLength) { + boolean[] testBlocks = new boolean[arrayLength]; + + // Test increasing amount of common blocks + for (int c = 1; c <= testBlocks.length; ++c) { + testBlocks[c - 1] = true; + + assertCommonBlock(c - 1, testBlocks); + } + } + } + + private void assertCommonBlock(int expectedIndex, boolean[] testBlocks) { + int commonBlockIndex = findCommonBlockIndex(testBlocks); + assertEquals(expectedIndex, commonBlockIndex); + } + + private int findCommonBlockIndex(boolean[] testBlocks) { + int low = 1; + int high = testBlocks.length - 1; + + while (low <= high) { + int mid = (low + high) >>> 1; + + if (testBlocks[mid]) + low = mid + 1; + else + high = mid - 1; + } + + return low - 1; + } + } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index 6e56992e..08bd26be 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -12,6 +12,7 @@ import org.junit.Before; import org.junit.Test; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; import org.qortal.repository.DataException; import org.qortal.test.common.Common; @@ -28,7 +29,7 @@ public class BtcTests extends Common { } @Test - public void testGetMedianBlockTime() throws BlockStoreException { + public void testGetMedianBlockTime() throws BlockStoreException, BitcoinException { System.out.println(String.format("Starting BTC instance...")); BTC btc = BTC.getInstance(); System.out.println(String.format("BTC instance started")); @@ -50,7 +51,7 @@ public class BtcTests extends Common { } @Test - public void testFindP2shSecret() { + public void testFindP2shSecret() throws BitcoinException { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; @@ -105,7 +106,7 @@ public class BtcTests extends Common { } @Test - public void testGetUnusedReceiveAddress() { + public void testGetUnusedReceiveAddress() throws BitcoinException { BTC btc = BTC.getInstance(); String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index 935d83eb..e7d96bc1 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -16,6 +16,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -135,11 +136,7 @@ public class CheckP2SH { System.out.println(String.format("Too soon (%s) to redeem based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); // Check P2SH is funded - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) { - System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); - System.exit(2); - } + long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) @@ -164,7 +161,9 @@ public class CheckP2SH { System.exit(2); } } catch (DataException e) { - throw new RuntimeException("Repository issue: " + e.getMessage()); + System.err.println("Repository issue: " + e.getMessage()); + } catch (BitcoinException e) { + System.err.println("Bitcoin issue: " + e.getMessage()); } } diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java index 992af2ee..99123763 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -11,8 +11,11 @@ import org.bitcoinj.script.ScriptBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.Test; +import org.qortal.crosschain.BitcoinException; +import org.qortal.crosschain.BitcoinTransaction; import org.qortal.crosschain.ElectrumX; -import org.qortal.crosschain.ElectrumX.UnspentOutput; +import org.qortal.crosschain.TransactionHash; +import org.qortal.crosschain.UnspentOutput; import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; @@ -34,26 +37,36 @@ public class ElectrumXTests { } @Test - public void testGetCurrentHeight() { + public void testGetCurrentHeight() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); - Integer height = electrumX.getCurrentHeight(); + int height = electrumX.getCurrentHeight(); - assertNotNull(height); assertTrue(height > 10000); System.out.println("Current TEST3 height: " + height); } @Test - public void testGetRecentBlocks() { + public void testInvalidRequest() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + try { + electrumX.getBlockHeaders(-1, -1); + } catch (BitcoinException e) { + // Should throw due to negative start block height + return; + } + + fail("Negative start block height should cause error"); + } + + @Test + public void testGetRecentBlocks() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); - Integer height = electrumX.getCurrentHeight(); - assertNotNull(height); + int height = electrumX.getCurrentHeight(); assertTrue(height > 10000); List recentBlockHeaders = electrumX.getBlockHeaders(height - 11, 11); - assertNotNull(recentBlockHeaders); System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size())); for (int i = 0; i < recentBlockHeaders.size(); ++i) { @@ -67,42 +80,39 @@ public class ElectrumXTests { } @Test - public void testGetP2PKHBalance() { + public void testGetP2PKHBalance() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - Long balance = electrumX.getBalance(script); + long balance = electrumX.getConfirmedBalance(script); - assertNotNull(balance); assertTrue(balance > 0L); System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); } @Test - public void testGetP2SHBalance() { + public void testGetP2SHBalance() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - Long balance = electrumX.getBalance(script); + long balance = electrumX.getConfirmedBalance(script); - assertNotNull(balance); assertTrue(balance > 0L); System.out.println(String.format("TestNet address %s has balance: %d sats / %d.%08d BTC", address, balance, (balance / 100000000L), (balance % 100000000L))); } @Test - public void testGetUnspentOutputs() { + public void testGetUnspentOutputs() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List unspentOutputs = electrumX.getUnspentOutputs(script); + List unspentOutputs = electrumX.getUnspentOutputs(script, false); - assertNotNull(unspentOutputs); assertFalse(unspentOutputs.isEmpty()); for (UnspentOutput unspentOutput : unspentOutputs) @@ -110,27 +120,68 @@ public class ElectrumXTests { } @Test - public void testGetRawTransaction() { + public void testGetRawTransaction() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes(); byte[] rawTransactionBytes = electrumX.getRawTransaction(txHash); - assertNotNull(rawTransactionBytes); + assertFalse(rawTransactionBytes.length == 0); } @Test - public void testGetAddressTransactions() { + public void testGetUnknownRawTransaction() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + byte[] txHash = HashCode.fromString("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0").asBytes(); + + try { + electrumX.getRawTransaction(txHash); + fail("Bitcoin transaction should be unknown and hence throw exception"); + } catch (BitcoinException e) { + if (!(e instanceof BitcoinException.NotFoundException)) + fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); + } + } + + @Test + public void testGetTransaction() throws BitcoinException { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + String txHash = "7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af"; + + BitcoinTransaction transaction = electrumX.getTransaction(txHash); + + assertNotNull(transaction); + assertTrue(transaction.txHash.equals(txHash)); + } + + @Test + public void testGetUnknownTransaction() { + ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + + String txHash = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"; + + try { + electrumX.getTransaction(txHash); + fail("Bitcoin transaction should be unknown and hence throw exception"); + } catch (BitcoinException e) { + if (!(e instanceof BitcoinException.NotFoundException)) + fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); + } + } + + @Test + public void testGetAddressTransactions() throws BitcoinException { ElectrumX electrumX = ElectrumX.getInstance("TEST3"); Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List rawTransactions = electrumX.getAddressTransactions(script); + List transactionHashes = electrumX.getAddressTransactions(script, false); - assertNotNull(rawTransactions); - assertFalse(rawTransactions.isEmpty()); + assertFalse(transactionHashes.isEmpty()); } } diff --git a/src/test/java/org/qortal/test/btcacct/GetTransaction.java b/src/test/java/org/qortal/test/btcacct/GetTransaction.java index 7f42b10b..49e1f966 100644 --- a/src/test/java/org/qortal/test/btcacct/GetTransaction.java +++ b/src/test/java/org/qortal/test/btcacct/GetTransaction.java @@ -7,6 +7,7 @@ import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.TransactionOutput; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.crosschain.BTC; +import org.qortal.crosschain.BitcoinException; import org.qortal.settings.Settings; import com.google.common.hash.HashCode; @@ -46,9 +47,11 @@ public class GetTransaction { } // Grab all outputs from transaction - List fundingOutputs = BTC.getInstance().getOutputs(transactionId); - if (fundingOutputs == null) { - System.out.println(String.format("Transaction not found")); + List fundingOutputs; + try { + fundingOutputs = BTC.getInstance().getOutputs(transactionId); + } catch (BitcoinException e) { + System.out.println(String.format("Transaction not found (or error occurred)")); return; } diff --git a/src/test/java/org/qortal/test/btcacct/P2shTests.java b/src/test/java/org/qortal/test/btcacct/P2shTests.java new file mode 100644 index 00000000..075b6586 --- /dev/null +++ b/src/test/java/org/qortal/test/btcacct/P2shTests.java @@ -0,0 +1,53 @@ +package org.qortal.test.btcacct; + +import static org.junit.Assert.*; + +import java.util.Arrays; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.crosschain.BTC; +import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class P2shTests extends Common { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); // TestNet3 + } + + @After + public void afterTest() { + BTC.resetForTesting(); + } + + @Test + public void testFindP2shSecret() throws BitcoinException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); + + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); + byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); + + assertNotNull(secret); + assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); + } + + @Test + public void testDetermineP2shStatus() throws BitcoinException { + // This actually exists on TEST3 but can take a while to fetch + String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + + BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddress, 1L); + + System.out.println(String.format("P2SH %s status: %s", p2shAddress, p2shStatus.name())); + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 761d4796..0ca20608 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -19,6 +19,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -136,7 +137,14 @@ public class Redeem { System.out.println("\nProcessing:"); - long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + long medianBlockTime; + try { + medianBlockTime = BTC.getInstance().getMedianBlockTime(); + } catch (BitcoinException e1) { + System.err.println("Unable to determine median block time"); + System.exit(2); + return; + } System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); long now = System.currentTimeMillis(); @@ -147,18 +155,24 @@ public class Redeem { } // Check P2SH is funded - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) { + long p2shBalance; + try { + p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); + } catch (BitcoinException e) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); + return; } System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (fundingOutputs == null) { + List fundingOutputs; + try { + fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); + } catch (BitcoinException e) { System.err.println(String.format("Can't find outputs for P2SH")); System.exit(2); + return; } System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index 6222bb83..184985d9 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -19,6 +19,7 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCP2SH; +import org.qortal.crosschain.BitcoinException; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -135,7 +136,14 @@ public class Refund { System.out.println("\nProcessing:"); - long medianBlockTime = BTC.getInstance().getMedianBlockTime(); + long medianBlockTime; + try { + medianBlockTime = BTC.getInstance().getMedianBlockTime(); + } catch (BitcoinException e) { + System.err.println("Unable to determine median block time"); + System.exit(2); + return; + } System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); long now = System.currentTimeMillis(); @@ -151,18 +159,24 @@ public class Refund { } // Check P2SH is funded - Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); - if (p2shBalance == null) { + long p2shBalance; + try { + p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); + } catch (BitcoinException e) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); + return; } System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (fundingOutputs == null) { + List fundingOutputs; + try { + fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); + } catch (BitcoinException e) { System.err.println(String.format("Can't find outputs for P2SH")); System.exit(2); + return; } System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : "")));