diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 1161dc63..d3919d9b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -19,11 +19,14 @@ import org.qortal.api.model.CrossChainTradeSummary; 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.ForeignBlockchainException; import org.qortal.crosschain.SupportedBlockchain; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TransactionSummary; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.MessageTransactionData; import org.qortal.data.transaction.TransactionData; @@ -47,6 +50,7 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import java.util.*; import java.util.function.Supplier; +import java.util.stream.Collectors; @Path("/crosschain") @Tag(name = "Cross-Chain") @@ -497,6 +501,111 @@ public class CrossChainResource { } } + @POST + @Path("/p2sh") + @Operation( + summary = "Returns P2SH Address", + description = "Get the P2SH address to lock foreign coin in a cross chain trade for QORT", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "the AT address", + example = "AKFnu9yBp7tUAc5HAphhfCxRZTYoeKXgUy" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "address")) + ) + } + ) + @ApiErrors({ApiError.ADDRESS_UNKNOWN, ApiError.INVALID_CRITERIA}) + @SecurityRequirement(name = "apiKey") + public String getForeignP2SH(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String atAddress) { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = repository.getATRepository().fromATAddress(atAddress); + if (atData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + + if( acct == null || !(acct.getBlockchain() instanceof Bitcoiny) ) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + + Optional p2sh + = CrossChainUtils.getP2ShAddressForAT(atAddress, repository, bitcoiny, crossChainTradeData); + + if(p2sh.isPresent()){ + return p2sh.get(); + } + else{ + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + } + } + catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); + } + } + + @POST + @Path("/txactivity") + @Operation( + summary = "Returns Foreign Transaction Activity", + description = "Get the activity related to foreign coin trading", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = TransactionSummary.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public List getForeignTransactionActivity(@HeaderParam(Security.API_KEY_HEADER) String apiKey, @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) { + Security.checkApiCallAllowed(request); + + if (!(foreignBlockchain.getInstance() instanceof Bitcoiny)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Bitcoiny bitcoiny = (Bitcoiny) foreignBlockchain.getInstance() ; + + org.bitcoinj.core.Context.propagate( bitcoiny.getBitcoinjContext() ); + + try (final Repository repository = RepositoryManager.getRepository()) { + + // sort from last lock to first lock + return CrossChainUtils + .getForeignTradeSummaries(foreignBlockchain, repository, bitcoiny).stream() + .sorted(Comparator.comparing(TransactionSummary::getLockingTimestamp).reversed()) + .collect(Collectors.toList()); + } + catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, e.getMessage()); + } + catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage()); + } + } + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) diff --git a/src/main/java/org/qortal/api/resource/CrossChainUtils.java b/src/main/java/org/qortal/api/resource/CrossChainUtils.java index 6e631b7a..b07a9d6c 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainUtils.java +++ b/src/main/java/org/qortal/api/resource/CrossChainUtils.java @@ -2,12 +2,28 @@ package org.qortal.api.resource; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; import org.bitcoinj.core.Coin; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + import org.qortal.crosschain.*; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.AtomicTransactionData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.crosschain.TransactionSummary; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + public class CrossChainUtils { private static final Logger LOGGER = LogManager.getLogger(CrossChainUtils.class); @@ -85,4 +101,320 @@ public class CrossChainUtils { return String.valueOf(bitcoiny.getFeeCeiling()); } + + /** + * Get P2Sh Address For AT + * + * @param atAddress the AT address + * @param repository the repository + * @param bitcoiny the blockchain data + * @param crossChainTradeData the trade data + * + * @return the p2sh address for the trade, if there is one + * + * @throws DataException + */ + public static Optional getP2ShAddressForAT( + String atAddress, + Repository repository, + Bitcoiny bitcoiny, + CrossChainTradeData crossChainTradeData) throws DataException { + + // get the trade bot data for the AT address + Optional tradeBotDataOptional + = repository.getCrossChainRepository() + .getAllTradeBotData().stream() + .filter(data -> data.getAtAddress().equals(atAddress)) + .findFirst(); + + if( tradeBotDataOptional.isEmpty() ) + return Optional.empty(); + + TradeBotData tradeBotData = tradeBotDataOptional.get(); + + // return the p2sh address from the trade bot + return getP2ShFromTradeBot(bitcoiny, crossChainTradeData, tradeBotData); + } + + /** + * Get Foreign Trade Summaries + * + * @param foreignBlockchain the blockchain traded on + * @param repository the repository + * @param bitcoiny data for the blockchain trade on + * @return + * @throws DataException + * @throws ForeignBlockchainException + */ + public static List getForeignTradeSummaries( + SupportedBlockchain foreignBlockchain, + Repository repository, + Bitcoiny bitcoiny) throws DataException, ForeignBlockchainException { + + // get all the AT address for the given blockchain + List atAddresses + = repository.getCrossChainRepository().getAllTradeBotData().stream() + .filter(data -> foreignBlockchain.name().toLowerCase().equals(data.getForeignBlockchain().toLowerCase())) + //.filter( data -> data.getForeignKey().equals( xpriv )) // TODO + .map(data -> data.getAtAddress()) + .collect(Collectors.toList()); + + List summaries = new ArrayList<>( atAddresses.size() * 2 ); + + // for each AT address, gather the data and get foreign trade summary + for( String atAddress: atAddresses) { + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + + CrossChainTradeData crossChainTradeData = foreignBlockchain.getLatestAcct().populateTradeData(repository, atData); + + Optional address = getP2ShAddressForAT(atAddress,repository, bitcoiny, crossChainTradeData); + + if( address.isPresent()){ + summaries.add( getForeignTradeSummary( bitcoiny, address.get(), atAddress ) ); + } + } + + return summaries; + } + + /** + * Get P2Sh From Trade Bot + * + * Get P2Sh address from the trade bot + * + * @param bitcoiny the blockchain for the trade + * @param crossChainTradeData the cross cahin data for the trade + * @param tradeBotData the data from the trade bot + * + * @return the address, original format + */ + private static Optional getP2ShFromTradeBot( + Bitcoiny bitcoiny, + CrossChainTradeData crossChainTradeData, + TradeBotData tradeBotData) { + + // Pirate Chain does not support this + if( SupportedBlockchain.PIRATECHAIN.name().equals(tradeBotData.getForeignBlockchain())) return Optional.empty(); + + // need to get the trade PKH from the trade bot + if( tradeBotData.getTradeForeignPublicKeyHash() == null ) return Optional.empty(); + + // need to get the lock time from the trade bot + if( tradeBotData.getLockTimeA() == null ) return Optional.empty(); + + // need to get the creator PKH from the trade bot + if( crossChainTradeData.creatorForeignPKH == null ) return Optional.empty(); + + // need to get the secret from the trade bot + if( tradeBotData.getHashOfSecret() == null ) return Optional.empty(); + + // if we have the necessary data from the trade bot, + // then build the redeem script necessary to facilitate the trade + byte[] redeemScriptBytes + = BitcoinyHTLC.buildScript( + tradeBotData.getTradeForeignPublicKeyHash(), + tradeBotData.getLockTimeA(), + crossChainTradeData.creatorForeignPKH, + tradeBotData.getHashOfSecret() + ); + + + String p2shAddress = bitcoiny.deriveP2shAddress(redeemScriptBytes); + + return Optional.of(p2shAddress); + } + + /** + * Get Foreign Trade Summary + * + * @param bitcoiny the blockchain the trade occurred on + * @param p2shAddress the p2sh address + * @param atAddress the AT address the p2sh address is derived from + * + * @return the summary + * + * @throws ForeignBlockchainException + */ + public static TransactionSummary getForeignTradeSummary(Bitcoiny bitcoiny, String p2shAddress, String atAddress) + throws ForeignBlockchainException { + Script outputScript = ScriptBuilder.createOutputScript( + Address.fromString(bitcoiny.getNetworkParameters(), p2shAddress)); + + List hashes + = bitcoiny.getAddressTransactions( outputScript.getProgram(), true); + + TransactionSummary summary; + + if(hashes.isEmpty()){ + summary + = new TransactionSummary( + atAddress, + p2shAddress, + "N/A", + "N/A", + 0, + 0, + 0, + 0, + "N/A", + 0, + 0, + 0, + 0); + } + else if( hashes.size() == 1) { + AtomicTransactionData data = buildTransactionData(bitcoiny, hashes.get(0)); + summary = new TransactionSummary( + atAddress, + p2shAddress, + "N/A", + data.hash.txHash, + data.timestamp, + data.totalAmount, + getTotalInput(bitcoiny, data.inputs) - data.totalAmount, + data.size, + "N/A", + 0, + 0, + 0, + 0); + } + // otherwise assuming there is 2 and only 2 hashes + else { + List atomicTransactionDataList = new ArrayList<>(2); + + // hashes -> data + for( TransactionHash hash : hashes){ + atomicTransactionDataList.add(buildTransactionData(bitcoiny,hash)); + } + + // sort the transaction data by time + List sorted + = atomicTransactionDataList.stream() + .sorted((data1, data2) -> data1.timestamp.compareTo(data2.timestamp)) + .collect(Collectors.toList()); + + // build the summary using the first 2 transactions + summary = buildForeignTradeSummary(atAddress, p2shAddress, sorted.get(0), sorted.get(1), bitcoiny); + } + return summary; + } + + /** + * Build Foreign Trade Summary + * + * @param p2shValue the p2sh address, original format + * @param lockingTransaction the transaction lock the foreighn coin + * @param unlockingTransaction the transaction to unlock the foreign coin + * @param bitcoiny the blockchain the trade occurred on + * + * @return + * + * @throws ForeignBlockchainException + */ + private static TransactionSummary buildForeignTradeSummary( + String atAddress, + String p2shValue, + AtomicTransactionData lockingTransaction, + AtomicTransactionData unlockingTransaction, + Bitcoiny bitcoiny) throws ForeignBlockchainException { + + // get sum of the relevant inputs for each transaction + long lockingTotalInput = getTotalInput(bitcoiny, lockingTransaction.inputs); + long unlockingTotalInput = getTotalInput(bitcoiny, unlockingTransaction.inputs); + + // find the address that has output that matches the total input + Optional, Long>> addressValue + = lockingTransaction.valueByAddress.entrySet().stream() + .filter(entry -> entry.getValue() == unlockingTotalInput).findFirst(); + + // set that matching address, if found + String p2shAddress; + if( addressValue.isPresent() && addressValue.get().getKey().size() == 1 ){ + p2shAddress = addressValue.get().getKey().get(0); + } + else { + p2shAddress = "N/A"; + } + + // build summaries with prepared values + // the fees are the total amount subtracted by the total transaction input + return new TransactionSummary( + atAddress, + p2shValue, + p2shAddress, + lockingTransaction.hash.txHash, + lockingTransaction.timestamp, + lockingTransaction.totalAmount, + lockingTotalInput - lockingTransaction.totalAmount, + lockingTransaction.size, + unlockingTransaction.hash.txHash, + unlockingTransaction.timestamp, + unlockingTransaction.totalAmount, + unlockingTotalInput - unlockingTransaction.totalAmount, + unlockingTransaction.size + ); + + } + + /** + * Build Transaction Data + * + * @param bitcoiny the coin for the transaction + * @param hash the hash for the transaction + * + * @return the data for the transaction + * + * @throws ForeignBlockchainException + */ + private static AtomicTransactionData buildTransactionData( Bitcoiny bitcoiny, TransactionHash hash) + throws ForeignBlockchainException { + + BitcoinyTransaction transaction = bitcoiny.getTransaction(hash.txHash); + + // destination address list -> value + Map, Long> valueByAddress = new HashMap<>(); + + // for each output in the transaction, index by address list + for( BitcoinyTransaction.Output output : transaction.outputs) { + valueByAddress.put(output.addresses, output.value); + } + + return new AtomicTransactionData( + hash, + transaction.timestamp, + transaction.inputs, + valueByAddress, + transaction.totalAmount, + transaction.size); + } + + /** + * Get Total Input + * + * Get the sum of all the inputs used in a list of inputs. + * + * @param bitcoiny the coin the inputs belong to + * @param inputs the inputs + * + * @return the sum + * + * @throws ForeignBlockchainException + */ + private static long getTotalInput(Bitcoiny bitcoiny, List inputs) + throws ForeignBlockchainException { + + long totalInputOut = 0; + + // for each input, add to total input, + // get the indexed transaction output value and add to total value + for( BitcoinyTransaction.Input input : inputs){ + + BitcoinyTransaction inputOut = bitcoiny.getTransaction(input.outputTxHash); + BitcoinyTransaction.Output output = inputOut.outputs.get(input.outputVout); + totalInputOut += output.value; + } + return totalInputOut; + } } \ No newline at end of file diff --git a/src/main/java/org/qortal/data/crosschain/AtomicTransactionData.java b/src/main/java/org/qortal/data/crosschain/AtomicTransactionData.java new file mode 100644 index 00000000..04c7a2a9 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/AtomicTransactionData.java @@ -0,0 +1,32 @@ +package org.qortal.data.crosschain; + +import org.qortal.crosschain.BitcoinyTransaction; +import org.qortal.crosschain.TransactionHash; + +import java.util.List; +import java.util.Map; + +public class AtomicTransactionData { + public final TransactionHash hash; + public final Integer timestamp; + public final List inputs; + public final Map, Long> valueByAddress; + public final long totalAmount; + public final int size; + + public AtomicTransactionData( + TransactionHash hash, + Integer timestamp, + List inputs, + Map, Long> valueByAddress, + long totalAmount, + int size) { + + this.hash = hash; + this.timestamp = timestamp; + this.inputs = inputs; + this.valueByAddress = valueByAddress; + this.totalAmount = totalAmount; + this.size = size; + } +} diff --git a/src/main/java/org/qortal/data/crosschain/TransactionSummary.java b/src/main/java/org/qortal/data/crosschain/TransactionSummary.java new file mode 100644 index 00000000..ac67a2f6 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/TransactionSummary.java @@ -0,0 +1,106 @@ +package org.qortal.data.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class TransactionSummary { + + private String atAddress; + private String p2shValue; + private String p2shAddress; + private String lockingHash; + private Integer lockingTimestamp; + private long lockingTotalAmount; + private long lockingFee; + private int lockingSize; + private String unlockingHash; + private Integer unlockingTimestamp; + private long unlockingTotalAmount; + private long unlockingFee; + private int unlockingSize; + + public TransactionSummary(){} + + public TransactionSummary( + String atAddress, + String p2shValue, + String p2shAddress, + String lockingHash, + Integer lockingTimestamp, + long lockingTotalAmount, + long lockingFee, + int lockingSize, + String unlockingHash, + Integer unlockingTimestamp, + long unlockingTotalAmount, + long unlockingFee, + int unlockingSize) { + + this.atAddress = atAddress; + this.p2shValue = p2shValue; + this.p2shAddress = p2shAddress; + this.lockingHash = lockingHash; + this.lockingTimestamp = lockingTimestamp; + this.lockingTotalAmount = lockingTotalAmount; + this.lockingFee = lockingFee; + this.lockingSize = lockingSize; + this.unlockingHash = unlockingHash; + this.unlockingTimestamp = unlockingTimestamp; + this.unlockingTotalAmount = unlockingTotalAmount; + this.unlockingFee = unlockingFee; + this.unlockingSize = unlockingSize; + } + + public String getAtAddress() { + return atAddress; + } + + public String getP2shValue() { + return p2shValue; + } + + public String getP2shAddress() { + return p2shAddress; + } + + public String getLockingHash() { + return lockingHash; + } + + public Integer getLockingTimestamp() { + return lockingTimestamp; + } + + public long getLockingTotalAmount() { + return lockingTotalAmount; + } + + public long getLockingFee() { + return lockingFee; + } + + public int getLockingSize() { + return lockingSize; + } + + public String getUnlockingHash() { + return unlockingHash; + } + + public Integer getUnlockingTimestamp() { + return unlockingTimestamp; + } + + public long getUnlockingTotalAmount() { + return unlockingTotalAmount; + } + + public long getUnlockingFee() { + return unlockingFee; + } + + public int getUnlockingSize() { + return unlockingSize; + } +}