From d4ef175c4dccf92da927090379102e6a046e28f0 Mon Sep 17 00:00:00 2001 From: kennycud Date: Sat, 4 Nov 2023 14:49:15 -0700 Subject: [PATCH 1/2] Added Address info support for Litecoin --- .../api/model/crosschain/AddressRequest.java | 17 ++++ .../resource/CrossChainLitecoinResource.java | 40 ++++++++ .../org/qortal/crosschain/AddressInfo.java | 78 +++++++++++++++ .../java/org/qortal/crosschain/Bitcoiny.java | 94 +++++++++++++++++++ .../org/qortal/crosschain/PathComparator.java | 29 ++++++ .../qortal/test/crosschain/BitcoinyTests.java | 35 +++++++ .../test/crosschain/BitcoinyTestsUtils.java | 42 +++++++++ 7 files changed, 335 insertions(+) create mode 100644 src/main/java/org/qortal/api/model/crosschain/AddressRequest.java create mode 100644 src/main/java/org/qortal/crosschain/AddressInfo.java create mode 100644 src/main/java/org/qortal/crosschain/PathComparator.java create mode 100644 src/test/java/org/qortal/test/crosschain/BitcoinyTestsUtils.java diff --git a/src/main/java/org/qortal/api/model/crosschain/AddressRequest.java b/src/main/java/org/qortal/api/model/crosschain/AddressRequest.java new file mode 100644 index 00000000..91749863 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/AddressRequest.java @@ -0,0 +1,17 @@ +package org.qortal.api.model.crosschain; + +import io.swagger.v3.oas.annotations.media.Schema; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +@XmlAccessorType(XmlAccessType.FIELD) +public class AddressRequest { + + @Schema(description = "Litecoin BIP32 extended public key", example = "tpub___________________________________________________________________________________________________________") + public String xpub58; + + public AddressRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 3e2ff799..439d19d5 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -24,7 +24,9 @@ import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; +import org.qortal.api.model.crosschain.AddressRequest; import org.qortal.api.model.crosschain.LitecoinSendRequest; +import org.qortal.crosschain.AddressInfo; import org.qortal.crosschain.ForeignBlockchainException; import org.qortal.crosschain.Litecoin; import org.qortal.crosschain.SimpleTransaction; @@ -150,6 +152,44 @@ public class CrossChainLitecoinResource { } } + @POST + @Path("/addressinfos") + @Operation( + summary = "Returns information for each address for a 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.APPLICATION_JSON, + schema = @Schema( + implementation = AddressRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(array = @ArraySchema( schema = @Schema( implementation = AddressInfo.class ) ) ) + ) + } + + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE}) + @SecurityRequirement(name = "apiKey") + public List getLitecoinAddressInfos(@HeaderParam(Security.API_KEY_HEADER) String apiKey, AddressRequest addressRequest) { + Security.checkApiCallAllowed(request); + + Litecoin litecoin = Litecoin.getInstance(); + + if (!litecoin.isValidDeterministicKey(addressRequest.xpub58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + try { + return litecoin.getWalletAddressInfos(addressRequest.xpub58); + } catch (ForeignBlockchainException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); + } + } + @POST @Path("/unusedaddress") @Operation( diff --git a/src/main/java/org/qortal/crosschain/AddressInfo.java b/src/main/java/org/qortal/crosschain/AddressInfo.java new file mode 100644 index 00000000..d1226d5a --- /dev/null +++ b/src/main/java/org/qortal/crosschain/AddressInfo.java @@ -0,0 +1,78 @@ +package org.qortal.crosschain; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.List; +import java.util.Objects; + +/** + * Class AddressInfo + */ +@XmlAccessorType(XmlAccessType.FIELD) +public class AddressInfo { + + private String address; + + private List path; + + private long value; + + private String pathAsString; + + private int transactionCount; + + public AddressInfo() { + } + + public AddressInfo(String address, List path, long value, String pathAsString, int transactionCount) { + this.address = address; + this.path = path; + this.value = value; + this.pathAsString = pathAsString; + this.transactionCount = transactionCount; + } + + public String getAddress() { + return address; + } + + public List getPath() { + return path; + } + + public long getValue() { + return value; + } + + public String getPathAsString() { + return pathAsString; + } + + public int getTransactionCount() { + return transactionCount; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AddressInfo that = (AddressInfo) o; + return value == that.value && transactionCount == that.transactionCount && Objects.equals(address, that.address) && Objects.equals(path, that.path) && Objects.equals(pathAsString, that.pathAsString); + } + + @Override + public int hashCode() { + return Objects.hash(address, path, value, pathAsString, transactionCount); + } + + @Override + public String toString() { + return "AddressInfo{" + + "address='" + address + '\'' + + ", path=" + path + + ", value=" + value + + ", pathAsString='" + pathAsString + '\'' + + ", transactionCount=" + transactionCount + + '}'; + } +} diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index d1523b50..cf727953 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -3,6 +3,7 @@ package org.qortal.crosschain; import java.util.*; import java.util.stream.Collectors; +import com.google.common.collect.ImmutableList; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bitcoinj.core.Address; @@ -488,6 +489,37 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + public List getWalletAddressInfos(String key58) throws ForeignBlockchainException { + List infos = new ArrayList<>(); + + for(DeterministicKey key : getWalletKeys(key58)) { + infos.add(buildAddressInfo(key)); + } + + return infos.stream() + .sorted(new PathComparator(1)) + .collect(Collectors.toList()); + } + + public AddressInfo buildAddressInfo(DeterministicKey key) throws ForeignBlockchainException { + + Address address = Address.fromKey(this.params, key, ScriptType.P2PKH); + + int transactionCount = getAddressTransactions(ScriptBuilder.createOutputScript(address).getProgram(), true).size(); + + return new AddressInfo( + address.toString(), + toIntegerList( key.getPath()), + summingUnspentOutputs(address.toString()), + key.getPathAsString(), + transactionCount); + } + + private static List toIntegerList(ImmutableList path) { + + return path.stream().map(ChildNumber::num).collect(Collectors.toList()); + } + public Set getWalletAddresses(String key58) throws ForeignBlockchainException { synchronized (this) { Context.propagate(bitcoinjContext); @@ -546,6 +578,61 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + private List getWalletKeys(String key58) throws ForeignBlockchainException { + synchronized (this) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + int unusedCounter = 0; + 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.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + } + } + + if (areAllKeysUnused) { + // No transactions + if (unusedCounter >= Settings.getInstance().getGapLimit()) { + // ... and we've hit our search limit + break; + } + // We haven't hit our search limit yet so increment the counter and keep looking + unusedCounter += WALLET_KEY_LOOKAHEAD_INCREMENT; + } else { + // Some keys in this batch were used, so reset the counter + unusedCounter = 0; + } + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + + return keys; + } + } + protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { long amount = 0; long total = 0L; @@ -818,6 +905,13 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + private Long summingUnspentOutputs(String walletAddress) throws ForeignBlockchainException { + return this.getUnspentOutputs(walletAddress).stream() + .map(TransactionOutput::getValue) + .mapToLong(Coin::longValue) + .sum(); + } + // Utility methods for others public static List simplifyWalletTransactions(List transactions) { diff --git a/src/main/java/org/qortal/crosschain/PathComparator.java b/src/main/java/org/qortal/crosschain/PathComparator.java new file mode 100644 index 00000000..7226b09e --- /dev/null +++ b/src/main/java/org/qortal/crosschain/PathComparator.java @@ -0,0 +1,29 @@ +package org.qortal.crosschain; + +/** + * Class PathComparator + */ +public class PathComparator implements java.util.Comparator { + + private int max; + + public PathComparator(int max) { + this.max = max; + } + + @Override + public int compare(AddressInfo info1, AddressInfo info2) { + return compareAtLevel(info1, info2, 0); + } + + private int compareAtLevel(AddressInfo info1, AddressInfo info2, int level) { + + if( level < 0 ) return 0; + + int compareTo = info1.getPath().get(level).compareTo(info2.getPath().get(level)); + + if(compareTo != 0 || level == max) return compareTo; + + return compareAtLevel(info1, info2,level + 1); + } +} diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java index b29fffd4..12e33767 100644 --- a/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinyTests.java @@ -4,6 +4,7 @@ import org.bitcoinj.core.Transaction; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.qortal.crosschain.AddressInfo; import org.qortal.crosschain.Bitcoiny; import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.crosschain.ForeignBlockchainException; @@ -11,6 +12,8 @@ import org.qortal.repository.DataException; import org.qortal.test.common.Common; import java.util.Arrays; +import java.util.List; +import java.util.Set; import static org.junit.Assert.*; @@ -127,4 +130,36 @@ public abstract class BitcoinyTests extends Common { System.out.println(address); } + @Test + public void testGenerateRootKeyForTesting() { + + String rootKey = BitcoinyTestsUtils.generateBip32RootKey( this.bitcoiny.getNetworkParameters() ); + + System.out.println(String.format(getCoinName() + " generated BIP32 Root Key: " + rootKey)); + + } + + @Test + public void testGetWalletAddresses() throws ForeignBlockchainException { + + String xprv58 = getDeterministicKey58(); + + Set addresses = this.bitcoiny.getWalletAddresses(xprv58); + + System.out.println( "root key = " + xprv58 ); + System.out.println( "keys ..."); + addresses.stream().forEach(System.out::println); + } + + @Test + public void testWalletAddressInfos() throws ForeignBlockchainException { + + String xprv58 = getDeterministicKey58(); + + List addressInfos = this.bitcoiny.getWalletAddressInfos(xprv58); + + System.out.println("address count = " + addressInfos.size() ); + System.out.println( "address infos ..." ); + addressInfos.forEach( System.out::println ); + } } diff --git a/src/test/java/org/qortal/test/crosschain/BitcoinyTestsUtils.java b/src/test/java/org/qortal/test/crosschain/BitcoinyTestsUtils.java new file mode 100644 index 00000000..a11b5b2f --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/BitcoinyTestsUtils.java @@ -0,0 +1,42 @@ +package org.qortal.test.crosschain; + +import com.google.common.collect.ImmutableList; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.crypto.ChildNumber; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.script.Script; +import org.bitcoinj.wallet.DeterministicKeyChain; +import org.bitcoinj.wallet.DeterministicSeed; +import org.bitcoinj.wallet.Wallet; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.Litecoin; +import org.qortal.repository.DataException; +import org.qortal.test.common.Common; + +public class BitcoinyTestsUtils { + + public static void main(String[] args) throws DataException, ForeignBlockchainException { + + Common.useDefaultSettings(); + + final String rootKey = generateBip32RootKey( Litecoin.LitecoinNet.TEST3.getParams()); + String address = Litecoin.getInstance().getUnusedReceiveAddress(rootKey); + + System.out.println("rootKey = " + rootKey); + System.out.println("address = " + address); + + System.exit(0); + } + + public static String generateBip32RootKey(NetworkParameters networkParameters) { + + final Wallet wallet = Wallet.createDeterministic(networkParameters, Script.ScriptType.P2PKH); + final DeterministicSeed seed = wallet.getKeyChainSeed(); + final DeterministicKeyChain keyChain = DeterministicKeyChain.builder().seed(seed).build(); + final ImmutableList path = keyChain.getAccountPath(); + final DeterministicKey parent = keyChain.getKeyByPath(path, true); + final String rootKey = parent.serializePrivB58(networkParameters); + + return rootKey; + } +} \ No newline at end of file From 0d64e809c78560760a88d9a0fff2e02f7db106ec Mon Sep 17 00:00:00 2001 From: kennycud Date: Sat, 4 Nov 2023 15:26:42 -0700 Subject: [PATCH 2/2] Ignoring test cases intended for Bitcoiny coins --- .../org/qortal/test/crosschain/PirateChainTests.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java index b212aea1..261b45b2 100644 --- a/src/test/java/org/qortal/test/crosschain/PirateChainTests.java +++ b/src/test/java/org/qortal/test/crosschain/PirateChainTests.java @@ -11,6 +11,7 @@ import org.qortal.crypto.Crypto; import org.qortal.transform.TransformationException; import java.util.List; +import java.util.Set; import static org.junit.Assert.*; import static org.qortal.crosschain.BitcoinyHTLC.Status.*; @@ -241,4 +242,12 @@ public class PirateChainTests extends BitcoinyTests { @Test @Ignore(value = "Needs adapting for Pirate Chain") public void testGetUnusedReceiveAddress() {} + + @Test + @Ignore(value = "Needs adapting for Pirate Chain") + public void testGetWalletAddresses() throws ForeignBlockchainException {} + + @Test + @Ignore(value = "Needs adapting for Pirate Chain") + public void testWalletAddressInfos() throws ForeignBlockchainException {} } \ No newline at end of file