Add API support for listing Bitcoin/Litecoin wallet transactions

This commit is contained in:
catbref 2020-12-07 16:40:12 +00:00
parent 23f0969b2d
commit 8c9cf4a02d
5 changed files with 266 additions and 0 deletions

View File

@ -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<BitcoinyTransaction> 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(

View File

@ -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<BitcoinyTransaction> 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(

View File

@ -330,6 +330,69 @@ public abstract class Bitcoiny implements ForeignBlockchain {
return balance.value;
}
public Set<BitcoinyTransaction> 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<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
Set<BitcoinyTransaction> 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<TransactionHash> 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<DeterministicKey> allLeafKeys = keyChain.getLeafKeys();
// Add only new keys onto our list of keys to search
List<DeterministicKey> 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.
*

View File

@ -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<Output> 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<Input> inputs, List<Output> 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();
}
}

View File

@ -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 <xprv/xpub>"));
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<BitcoinyTransaction> 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));
}
}