From 8c9cf4a02da93b63e31ec9b891a76d9b70fd9d28 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 7 Dec 2020 16:40:12 +0000 Subject: [PATCH] Add API support for listing Bitcoin/Litecoin wallet transactions --- .../resource/CrossChainBitcoinResource.java | 42 +++++++++++ .../resource/CrossChainLitecoinResource.java | 42 +++++++++++ .../java/org/qortal/crosschain/Bitcoiny.java | 63 ++++++++++++++++ .../crosschain/BitcoinyTransaction.java | 47 ++++++++++++ .../crosschain/GetWalletTransactions.java | 72 +++++++++++++++++++ 5 files changed, 266 insertions(+) create mode 100644 src/test/java/org/qortal/test/crosschain/GetWalletTransactions.java diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java index f04b5a04..ef25892d 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -1,12 +1,15 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Set; + import javax.servlet.http.HttpServletRequest; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -20,6 +23,7 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.crosschain.BitcoinSendRequest; import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.BitcoinyTransaction; import org.qortal.crosschain.ForeignBlockchainException; @Path("/crosschain/btc") @@ -67,6 +71,44 @@ public class CrossChainBitcoinResource { return balance.toString(); } + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpub___________________________________________________________________________________________________________" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public Set getBitcoinWalletTransactions(String key58) { + Security.checkApiCallAllowed(request); + + Bitcoin bitcoin = Bitcoin.getInstance(); + + if (!bitcoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return bitcoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 5a8cd712..ea7a860f 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -1,12 +1,15 @@ package org.qortal.api.resource; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Set; + import javax.servlet.http.HttpServletRequest; import javax.ws.rs.POST; import javax.ws.rs.Path; @@ -19,6 +22,7 @@ import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.crosschain.LitecoinSendRequest; +import org.qortal.crosschain.BitcoinyTransaction; import org.qortal.crosschain.ForeignBlockchainException; import org.qortal.crosschain.Litecoin; @@ -67,6 +71,44 @@ public class CrossChainLitecoinResource { return balance.toString(); } + @POST + @Path("/wallettransactions") + @Operation( + summary = "Returns transactions for hierarchical, deterministic BIP32 wallet", + description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private/public key in base58", + example = "tpub___________________________________________________________________________________________________________" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + public Set getLitecoinWalletTransactions(String key58) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(key58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return litecoin.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/send") @Operation( diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index e4c9235d..1f682a93 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -330,6 +330,69 @@ public abstract class Bitcoiny implements ForeignBlockchain { return balance.value; } + public Set getWalletTransactions(String key58) throws ForeignBlockchainException { + Context.propagate(bitcoinjContext); + + final DeterministicKey watchKey = DeterministicKey.deserializeB58(null, key58, this.params); + + Wallet wallet; + if (watchKey.hasPrivKey()) + wallet = Wallet.fromSpendingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + else + wallet = Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); + + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set walletTransactions = new HashSet<>(); + + int ki = 0; + do { + boolean areAllKeysUnused = true; + + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.blockchain.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + + for (TransactionHash transactionHash : historicTransactionHashes) + walletTransactions.add(this.getTransaction(transactionHash.txHash)); + } + } + + if (!areAllKeysUnused) { + // Generate some more keys + keyChain.setLookaheadSize(keyChain.getLookaheadSize() + WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + // This returns all keys, including those already in 'keys' + List allLeafKeys = keyChain.getLeafKeys(); + // Add only new keys onto our list of keys to search + List newKeys = allLeafKeys.subList(ki, allLeafKeys.size()); + keys.addAll(newKeys); + // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again + + // Process new keys + } + + // If we have processed all keys, then we're done + } while (ki < keys.size()); + + return walletTransactions; + } + /** * Returns first unused receive address given 'm' BIP32 key. * diff --git a/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java index f7c3f47e..e6f84de6 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java @@ -3,6 +3,10 @@ package org.qortal.crosschain; import java.util.List; import java.util.stream.Collectors; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) public class BitcoinyTransaction { public final String txHash; @@ -17,6 +21,14 @@ public class BitcoinyTransaction { public final String outputTxHash; public final int outputVout; + // For JAXB + protected Input() { + this.scriptSig = null; + this.sequence = 0; + this.outputTxHash = null; + this.outputVout = 0; + } + public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) { this.scriptSig = scriptSig; this.sequence = sequence; @@ -35,6 +47,12 @@ public class BitcoinyTransaction { public final String scriptPubKey; public final long value; + // For JAXB + protected Output() { + this.scriptPubKey = null; + this.value = 0; + } + public Output(String scriptPubKey, long value) { this.scriptPubKey = scriptPubKey; this.value = value; @@ -46,6 +64,16 @@ public class BitcoinyTransaction { } public final List outputs; + // For JAXB + protected BitcoinyTransaction() { + this.txHash = null; + this.size = 0; + this.locktime = 0; + this.timestamp = 0; + this.inputs = null; + this.outputs = null; + } + public BitcoinyTransaction(String txHash, int size, int locktime, Integer timestamp, List inputs, List outputs) { this.txHash = txHash; @@ -67,4 +95,23 @@ public class BitcoinyTransaction { this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")), this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t"))); } + + @Override + public boolean equals(Object other) { + if (other == this) + return true; + + if (!(other instanceof BitcoinyTransaction)) + return false; + + BitcoinyTransaction otherTransaction = (BitcoinyTransaction) other; + + return this.txHash.equals(otherTransaction.txHash); + } + + @Override + public int hashCode() { + return this.txHash.hashCode(); + } + } \ No newline at end of file diff --git a/src/test/java/org/qortal/test/crosschain/GetWalletTransactions.java b/src/test/java/org/qortal/test/crosschain/GetWalletTransactions.java new file mode 100644 index 00000000..5b168488 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/GetWalletTransactions.java @@ -0,0 +1,72 @@ +package org.qortal.test.crosschain; + +import java.security.Security; +import java.util.Comparator; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bitcoinj.core.AddressFormatException; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.crosschain.Bitcoiny; +import org.qortal.crosschain.BitcoinyTransaction; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.settings.Settings; + +public class GetWalletTransactions { + + static { + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static void usage(String error) { + if (error != null) + System.err.println(error); + + System.err.println(String.format("usage: GetWalletTransactions ")); + System.err.println(String.format("example (testnet): GetWalletTransactions tpubD6NzVbkrYhZ4X3jV96Wo3Kr8Au2v9cUUEmPRk1smwduFrRVfBjkkw49rRYjgff1fGSktFMfabbvv8b1dmfyLjjbDax6QGyxpsNsx5PXukCB")); + System.exit(1); + } + + public static void main(String[] args) { + if (args.length != 1) + usage(null); + + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + Settings.fileInstance("settings-test.json"); + + Bitcoiny bitcoiny = Litecoin.getInstance(); + + String key58 = null; + + try { + int argIndex = 0; + + key58 = args[argIndex++]; + + if (!bitcoiny.isValidDeterministicKey(key58)) + usage("Not valid xprv/xpub/tprv/tpub"); + } catch (NumberFormatException | AddressFormatException e) { + usage(String.format("Argument format exception: %s", e.getMessage())); + } + + // Grab all outputs from transaction + Set transactions = null; + try { + transactions = bitcoiny.getWalletTransactions(key58); + } catch (ForeignBlockchainException e) { + System.err.println(String.format("Failed to obtain wallet transactions: %s", e.getMessage())); + System.exit(1); + } + + System.out.println(String.format("Found %d transaction%s", transactions.size(), (transactions.size() != 1 ? "s" : ""))); + + for (BitcoinyTransaction transaction : transactions.stream().sorted(Comparator.comparingInt(t -> t.timestamp)).collect(Collectors.toList())) + System.out.println(String.format("%s", transaction)); + } + +}