diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java b/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java new file mode 100644 index 00000000..67a262fc --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeBotUtils.java @@ -0,0 +1,217 @@ +package org.qortal.controller.tradebot; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Transaction; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.resource.CrossChainUtils; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyHTLC; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crypto.Crypto; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.Base58; +import org.qortal.utils.NTP; +import org.qortal.transaction.Transaction.ValidationResult; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.qortal.controller.tradebot.TradeStates.State; + +public class TradeBotUtils { + + private static final Logger LOGGER = LogManager.getLogger(TradeBotUtils.class); + /** + * Creates trade-bot entries from the 'Alice' viewpoint, i.e. matching Bitcoiny coin to existing offers. + *

+ * Requires chosen trade offers from Bob, passed by crossChainTradeData + * and access to a Blockchain wallet via foreignKey. + *

+ * The crossChainTradeData contains the current trade offers state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Blockchain BIP32 hierarchical deterministic key, + * passed via foreignKey. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the foreignKey can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Blockchain main-net) + * or 'tprv' for (Blockchain test-net). + *

+ * It is envisaged that the value in foreignKey will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Blockchain amount expected by 'Bob'. + *

+ * If the Blockchain transaction is successfully broadcast to the network then + * we also send a MESSAGE to Bob's trade-bot to let them know; one message for each trade. + *

+ * The trade-bot entries are saved to the repository and the cross-chain trading process commences. + *

+ * + * @param repository for backing up the trade bot data + * @param crossChainTradeDataList chosen trade OFFERs that Alice wants to match + * @param receiveAddress Alice's Qortal address + * @param foreignKey funded wallet xprv in base58 + * @param bitcoiny the bitcoiny chain to match the sell offer with + * @return true if P2SH-A funding transaction successfully broadcast to Blockchain network, false otherwise + * @throws DataException + */ + public static AcctTradeBot.ResponseResult startResponseMultiple( + Repository repository, + ACCT acct, + List crossChainTradeDataList, + String receiveAddress, + String foreignKey, + Bitcoiny bitcoiny) throws DataException { + + // Check we have enough funds via foreignKey to fund P2SH to cover expectedForeignAmount + long now = NTP.getTime(); + long p2shFee; + try { + p2shFee = bitcoiny.getP2shFee(now); + } catch (ForeignBlockchainException e) { + LOGGER.debug("Couldn't estimate blockchain transaction fees?"); + return AcctTradeBot.ResponseResult.NETWORK_ISSUE; + } + + Map valueByP2shAddress = new HashMap<>(crossChainTradeDataList.size()); + + class DataCombiner{ + CrossChainTradeData crossChainTradeData; + TradeBotData tradeBotData; + String p2shAddress; + + public DataCombiner(CrossChainTradeData crossChainTradeData, TradeBotData tradeBotData, String p2shAddress) { + this.crossChainTradeData = crossChainTradeData; + this.tradeBotData = tradeBotData; + this.p2shAddress = p2shAddress; + } + } + + List dataToProcess = new ArrayList<>(); + + for(CrossChainTradeData crossChainTradeData : crossChainTradeDataList) { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretA = TradeBot.generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); + + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + // We need to generate lockTime-A: add tradeTimeout to now + int lockTimeA = (crossChainTradeData.tradeTimeout * 60) + (int) (now / 1000L); + byte[] receivingPublicKeyHash = Base58.decode(receiveAddress); // Actually the whole address, not just PKH + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acct.getClass().getSimpleName(), + State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value, + receiveAddress, + crossChainTradeData.qortalAtAddress, + now, + crossChainTradeData.qortAmount, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, + crossChainTradeData.foreignBlockchain, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedForeignAmount, + foreignKey, null, lockTimeA, receivingPublicKeyHash); + + // Attempt to backup the trade bot data + // Include tradeBotData as an additional parameter, since it's not in the repository yet + TradeBot.backupTradeBotData(repository, Arrays.asList(tradeBotData)); + + // Fee for redeem/refund is subtracted from P2SH-A balance. + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/; + + // P2SH-A to be funded + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScriptBytes); + + valueByP2shAddress.put(p2shAddress, amountA); + + dataToProcess.add(new DataCombiner(crossChainTradeData, tradeBotData, p2shAddress)); + } + + // Build transaction for funding P2SH-A + Transaction p2shFundingTransaction = bitcoiny.buildSpendMultiple(foreignKey, valueByP2shAddress, null); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); + return AcctTradeBot.ResponseResult.BALANCE_ISSUE; + } + + try { + bitcoiny.broadcastTransaction(p2shFundingTransaction); + } catch (ForeignBlockchainException e) { + // We couldn't fund P2SH-A at this time + LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); + return AcctTradeBot.ResponseResult.NETWORK_ISSUE; + } + + for(DataCombiner datumToProcess : dataToProcess ) { + // Attempt to send MESSAGE to Bob's Qortal trade address + TradeBotData tradeBotData = datumToProcess.tradeBotData; + + byte[] messageData = CrossChainUtils.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + CrossChainTradeData crossChainTradeData = datumToProcess.crossChainTradeData; + String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; + + boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); + if (!isMessageAlreadySent) { + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + + LOGGER.info("Computing nonce at difficulty {} for AT {} and recipient {}", messageTransaction.getPoWDifficulty(), tradeBotData.getAtAddress(), messageRecipient); + messageTransaction.computeNonce(); + MessageTransactionData newMessageTransactionData = (MessageTransactionData) messageTransaction.getTransactionData(); + LOGGER.info("Computed nonce {} at difficulty {}", newMessageTransactionData.getNonce(), messageTransaction.getPoWDifficulty()); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); + + if (messageTransaction.isSignatureValid()) { + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } else { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: signature invalid", messageRecipient)); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); + } + + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", datumToProcess.p2shAddress)); + } + + return AcctTradeBot.ResponseResult.OK; + } +} \ No newline at end of file