diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java index 5a50222a..9d33cd22 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -17,12 +17,14 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.crosschain.TradeBotCreateRequest; import org.qortal.api.model.crosschain.TradeBotRespondRequest; +import org.qortal.api.model.crosschain.TradeBotRespondRequests; import org.qortal.asset.Asset; import org.qortal.controller.Controller; import org.qortal.controller.tradebot.AcctTradeBot; import org.qortal.controller.tradebot.TradeBot; import org.qortal.crosschain.ACCT; import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.ForeignBlockchain; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crypto.Crypto; @@ -42,8 +44,10 @@ import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; +import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.stream.Collectors; @Path("/crosschain/tradebot") @@ -187,6 +191,39 @@ public class CrossChainTradeBotResource { public String tradeBotResponder(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotRespondRequest tradeBotRespondRequest) { Security.checkApiCallAllowed(request); + return createTradeBotResponse(tradeBotRespondRequest); + } + + @POST + @Path("/respondmultiple") + @Operation( + summary = "Respond to multiple trade offers. NOTE: WILL SPEND FUNDS!)", + description = "Start a new trade-bot entry to respond to chosen trade offers.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotRespondRequests.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + @SuppressWarnings("deprecation") + @SecurityRequirement(name = "apiKey") + public String tradeBotResponderMultiple(@HeaderParam(Security.API_KEY_HEADER) String apiKey, TradeBotRespondRequests tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + return createTradeBotResponseMultiple(tradeBotRespondRequest); + } + + private String createTradeBotResponse(TradeBotRespondRequest tradeBotRespondRequest) { final String atAddress = tradeBotRespondRequest.atAddress; // We prefer foreignKey to deprecated xprv58 @@ -257,6 +294,96 @@ public class CrossChainTradeBotResource { } } + private String createTradeBotResponseMultiple(TradeBotRespondRequests respondRequests) { + try (final Repository repository = RepositoryManager.getRepository()) { + + if (respondRequests.foreignKey == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + List crossChainTradeDataList = new ArrayList<>(respondRequests.addresses.size()); + Optional acct = Optional.empty(); + + for(String atAddress : respondRequests.addresses ) { + + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (respondRequests.receivingAddress == null || !Crypto.isValidAddress(respondRequests.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + final Long minLatestBlockTimestamp = NTP.getTime() - (60 * 60 * 1000L); + if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC); + + // Extract data from cross-chain trading AT + ATData atData = fetchAtDataWithChecking(repository, atAddress); + + // TradeBot uses AT's code hash to map to ACCT + ACCT acctUsingAtData = TradeBot.getInstance().getAcctUsingAtData(atData); + if (acctUsingAtData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + // if the optional is empty, + // then ensure the ACCT blockchain is a Bitcoiny blockchain and fill the optional + else if( acct.isEmpty() ) { + if( !(acctUsingAtData.getBlockchain() instanceof Bitcoiny) ) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + acct = Optional.of(acctUsingAtData); + } + // if the optional is filled, then ensure it is equal to the AT in this iteration + else if( !acctUsingAtData.getCodeBytesHash().equals(acct.get().getCodeBytesHash()) ) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + if (!acctUsingAtData.getBlockchain().isValidWalletKey(respondRequests.foreignKey)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + CrossChainTradeData crossChainTradeData = acctUsingAtData.populateTradeData(repository, atData); + if (crossChainTradeData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Check if there is a buy or a cancel request in progress for this trade + List txTypes = List.of(Transaction.TransactionType.MESSAGE); + List unconfirmed = repository.getTransactionRepository().getUnconfirmedTransactions(txTypes, null, 0, 0, false); + for (TransactionData transactionData : unconfirmed) { + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + if (Objects.equals(messageTransactionData.getRecipient(), atAddress)) { + // There is a pending request for this trade, so block this buy attempt to reduce the risk of refunds + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Trade has an existing buy request or is pending cancellation."); + } + } + + crossChainTradeDataList.add(crossChainTradeData); + } + + AcctTradeBot.ResponseResult result + = TradeBot.getInstance().startResponseMultiple( + repository, + acct.get(), + crossChainTradeDataList, + respondRequests.receivingAddress, + respondRequests.foreignKey, + (Bitcoiny) acct.get().getBlockchain()); + + switch (result) { + case OK: + return "true"; + + case BALANCE_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE); + + case NETWORK_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + + default: + return "false"; + } + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); + } + } + @DELETE @Operation( summary = "Delete completed trade", diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java index 3699bd2a..654513f2 100644 --- a/src/main/java/org/qortal/controller/tradebot/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -215,6 +215,41 @@ public class TradeBot implements Listener { return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress); } + /** + * Creates a trade-bot entries from the 'Alice' viewpoint, + * i.e. matching foreign blockchain currency to existing QORT offers. + *

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

+ * @param repository + * @param crossChainTradeDataList chosen trade OFFERs that Alice wants to match + * @param receiveAddress Alice's Qortal address to receive her QORT + * @param foreignKey foreign blockchain wallet key + * @param bitcoiny + * @throws DataException + */ + public ResponseResult startResponseMultiple( + Repository repository, + ACCT acct, + List crossChainTradeDataList, + String receiveAddress, + String foreignKey, + Bitcoiny bitcoiny) throws DataException { + AcctTradeBot acctTradeBot = findTradeBotForAcct(acct); + if (acctTradeBot == null) { + LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for %s", acct.getBlockchain())); + return ResponseResult.NETWORK_ISSUE; + } + + for( CrossChainTradeData tradeData : crossChainTradeDataList) { + // Check Alice doesn't already have an existing, on-going trade-bot entry for this AT. + if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(tradeData.qortalAtAddress, acctTradeBot.getEndStates())) + return ResponseResult.TRADE_ALREADY_EXISTS; + } + return TradeBotUtils.startResponseMultiple(repository, acct, crossChainTradeDataList, receiveAddress, foreignKey, bitcoiny); + } + public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException { TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); if (tradeBotData == null) diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 7f624e20..b1938639 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -343,6 +343,45 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + /** + * Returns bitcoinj transaction sending the recipient's amount to each recipient given. + * + * + * @param xprv58 the private master key + * @param amountByRecipient each amount to send indexed by the recipient to send to + * @param feePerByte the satoshis per byte + * + * @return the completed transaction, ready to broadcast + */ + public Transaction buildSpendMultiple(String xprv58, Map amountByRecipient, Long feePerByte) { + Context.propagate(bitcoinjContext); + + Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Transaction transaction = new Transaction(this.params); + + for(Map.Entry amountForRecipient : amountByRecipient.entrySet()) { + Address destination = Address.fromString(this.params, amountForRecipient.getKey()); + transaction.addOutput(Coin.valueOf(amountForRecipient.getValue()), destination); + } + + SendRequest sendRequest = SendRequest.forTx(transaction); + + if (feePerByte != null) + sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024 + else + // Allow override of default for TestNet3, etc. + sendRequest.feePerKb = this.getFeePerKb(); + + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } + /** * Get Spending Candidate Addresses *