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" : "")));