From 75d9a39d811b475e7a38574c41ee0c42abe4ab4f Mon Sep 17 00:00:00 2001 From: Thomas Kammerlocher Date: Mon, 25 Nov 2024 12:21:35 +0100 Subject: [PATCH] 213 provide hd wallet implementation (#363) * #213 rough sketch of needed functionality as a discussion basis * still work in progress * basic transaction working now. Still need to refactor a lot! But first good step. * #213 added WalletUtxoSupplier and removed getUtxo functionalities from Wallet.java Need to rework signing to get addresses. * #213 added stakekey registrations and address caching to wallet * #213 added Tests, WalletUtxoSupplier interface and DefaultWalletUtxoSupplier.java * #213 implemented signing with wallet. A UTXOSupplier will be passed. Thus removing the unneeded utxosupplier value in Wallet. * Refactor Tx sender wallet handling and clean up AbstractTx. Changed the sender wallet handling to return null instead of throwing an exception in Tx.java to allow flexibility in error management. Also, removed redundant 'amounts' field and its initialization from AbstractTx.java for a cleaner codebase. * Refactor TxBuilderContext and QuickTxBuilder Move the transaction building logic into a dedicated private method in `TxBuilderContext`. Refactor `QuickTxBuilder` to use the new build method and streamline the signing process, eliminating redundant code and improving code readability. * Refactor TxSigner to include TxBuilderContext * Normalize mnemonic phrase whitespaces * Update logging dependency to reload4j in build.gradle * Refactor: Change MnemonicUtil import path * Refactor Wallet signing and UTXO handling * Refactor Wallet class and update related tests Refactor Wallet methods and variables for better naming consistency. Remove unused methods and annotations. Add new test for scanning specific indexes in DefaultWalletUtxoSupplier. Update existing tests to align with the refactored method names and structures. * fix: changes to fix SonarQube errors --------- Co-authored-by: Satya --- .../cardano/client/account/Account.java | 38 +-- .../cardano/client/crypto/MnemonicUtil.java | 41 +++ .../client/crypto/bip39/MnemonicCode.java | 4 +- function/build.gradle | 1 + .../client/function/TxBuilderContext.java | 17 +- .../cardano/client/function/TxSigner.java | 7 +- .../function/helper/SignerProviders.java | 45 ++- .../function/helper/SignerProvidersTest.java | 10 +- hd-wallet/build.gradle | 29 ++ hd-wallet/specification.md | 25 ++ .../cardano/hdwallet/QuickTxBaseIT.java | 119 +++++++ .../cardano/hdwallet/QuickTxBuilderIT.java | 208 ++++++++++++ .../bloxbean/cardano/hdwallet/StakeTxIT.java | 312 ++++++++++++++++++ .../com/bloxbean/cardano/hdwallet/Wallet.java | 298 +++++++++++++++++ .../cardano/hdwallet/WalletException.java | 19 ++ .../cardano/hdwallet/model/WalletUtxo.java | 32 ++ .../supplier/DefaultWalletUtxoSupplier.java | 117 +++++++ .../hdwallet/supplier/WalletUtxoSupplier.java | 23 ++ .../bloxbean/cardano/hdwallet/WalletTest.java | 101 ++++++ .../DefaultWalletUtxoSupplierTest.java | 171 ++++++++++ quicktx/build.gradle | 1 + .../cardano/client/quicktx/AbstractTx.java | 3 + .../client/quicktx/QuickTxBuilder.java | 53 ++- .../cardano/client/quicktx/ScriptTx.java | 6 + .../bloxbean/cardano/client/quicktx/Tx.java | 37 +++ settings.gradle | 1 + 26 files changed, 1648 insertions(+), 70 deletions(-) create mode 100644 crypto/src/main/java/com/bloxbean/cardano/client/crypto/MnemonicUtil.java create mode 100644 hd-wallet/build.gradle create mode 100644 hd-wallet/specification.md create mode 100644 hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBaseIT.java create mode 100644 hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBuilderIT.java create mode 100644 hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/StakeTxIT.java create mode 100644 hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/Wallet.java create mode 100644 hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/WalletException.java create mode 100644 hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/model/WalletUtxo.java create mode 100644 hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplier.java create mode 100644 hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/WalletUtxoSupplier.java create mode 100644 hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/WalletTest.java create mode 100644 hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplierTest.java diff --git a/core/src/main/java/com/bloxbean/cardano/client/account/Account.java b/core/src/main/java/com/bloxbean/cardano/client/account/Account.java index defa2228..f6ed7c7a 100644 --- a/core/src/main/java/com/bloxbean/cardano/client/account/Account.java +++ b/core/src/main/java/com/bloxbean/cardano/client/account/Account.java @@ -2,16 +2,14 @@ import com.bloxbean.cardano.client.address.Address; import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.crypto.MnemonicUtil; import com.bloxbean.cardano.client.address.Credential; import com.bloxbean.cardano.client.common.model.Network; import com.bloxbean.cardano.client.common.model.Networks; import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair; -import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode; -import com.bloxbean.cardano.client.crypto.bip39.MnemonicException; import com.bloxbean.cardano.client.crypto.bip39.Words; import com.bloxbean.cardano.client.crypto.cip1852.CIP1852; import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; -import com.bloxbean.cardano.client.exception.AddressRuntimeException; import com.bloxbean.cardano.client.exception.CborDeserializationException; import com.bloxbean.cardano.client.exception.CborSerializationException; import com.bloxbean.cardano.client.governance.keys.CommitteeColdKey; @@ -22,9 +20,6 @@ import com.bloxbean.cardano.client.util.HexUtil; import com.fasterxml.jackson.annotation.JsonIgnore; -import java.util.Arrays; -import java.util.stream.Collectors; - /** * Create and manage secrets, and perform account-based work such as signing transactions. */ @@ -87,7 +82,8 @@ public Account(Network network, int index) { public Account(Network network, DerivationPath derivationPath, Words noOfWords) { this.network = network; this.derivationPath = derivationPath; - generateNew(noOfWords); + this.mnemonic = MnemonicUtil.generateNew(noOfWords); + baseAddress(); } /** @@ -141,7 +137,7 @@ public Account(Network network, String mnemonic, DerivationPath derivationPath) this.mnemonic = mnemonic; this.accountKey = null; this.derivationPath = derivationPath; - validateMnemonic(); + MnemonicUtil.validateMnemonic(this.mnemonic); baseAddress(); } @@ -485,32 +481,6 @@ public Transaction signWithCommitteeHotKey(Transaction transaction) { return TransactionSigner.INSTANCE.sign(transaction, getCommitteeHotKeyPair()); } - private void generateNew(Words noOfWords) { - String mnemonic = null; - try { - mnemonic = MnemonicCode.INSTANCE.createMnemonic(noOfWords).stream().collect(Collectors.joining(" ")); - } catch (MnemonicException.MnemonicLengthException e) { - throw new RuntimeException("Mnemonic generation failed", e); - } - this.mnemonic = mnemonic; - baseAddress(); - } - - private void validateMnemonic() { - if (mnemonic == null) { - throw new AddressRuntimeException("Mnemonic cannot be null"); - } - - mnemonic = mnemonic.replaceAll("\\s+", " "); - String[] words = mnemonic.split("\\s+"); - - try { - MnemonicCode.INSTANCE.check(Arrays.asList(words)); - } catch (MnemonicException e) { - throw new AddressRuntimeException("Invalid mnemonic phrase", e); - } - } - private HdKeyPair getHdKeyPair() { HdKeyPair hdKeyPair; if (mnemonic == null || mnemonic.trim().length() == 0) { diff --git a/crypto/src/main/java/com/bloxbean/cardano/client/crypto/MnemonicUtil.java b/crypto/src/main/java/com/bloxbean/cardano/client/crypto/MnemonicUtil.java new file mode 100644 index 00000000..c13cad99 --- /dev/null +++ b/crypto/src/main/java/com/bloxbean/cardano/client/crypto/MnemonicUtil.java @@ -0,0 +1,41 @@ +package com.bloxbean.cardano.client.crypto; + +import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode; +import com.bloxbean.cardano.client.crypto.bip39.MnemonicException; +import com.bloxbean.cardano.client.crypto.bip39.Words; +import com.bloxbean.cardano.client.exception.AddressRuntimeException; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class MnemonicUtil { + + private MnemonicUtil() { + + } + + public static void validateMnemonic(String mnemonic) { + if (mnemonic == null) { + throw new AddressRuntimeException("Mnemonic cannot be null"); + } + + mnemonic = mnemonic.replaceAll("\\s+", " "); + String[] words = mnemonic.split("\\s+"); + + try { + MnemonicCode.INSTANCE.check(Arrays.asList(words)); + } catch (MnemonicException e) { + throw new AddressRuntimeException("Invalid mnemonic phrase", e); + } + } + + public static String generateNew(Words noOfWords) { + String mnemonic = null; + try { + mnemonic = MnemonicCode.INSTANCE.createMnemonic(noOfWords).stream().collect(Collectors.joining(" ")); + } catch (MnemonicException.MnemonicLengthException e) { + throw new AddressRuntimeException("Mnemonic generation failed", e); + } + return mnemonic; + } +} diff --git a/crypto/src/main/java/com/bloxbean/cardano/client/crypto/bip39/MnemonicCode.java b/crypto/src/main/java/com/bloxbean/cardano/client/crypto/bip39/MnemonicCode.java index ed44abc3..223341fb 100644 --- a/crypto/src/main/java/com/bloxbean/cardano/client/crypto/bip39/MnemonicCode.java +++ b/crypto/src/main/java/com/bloxbean/cardano/client/crypto/bip39/MnemonicCode.java @@ -142,7 +142,9 @@ public static byte[] toSeed(List words, String passphrase) { public byte[] toEntropy(String mnemonicPhrase) throws MnemonicException.MnemonicLengthException, MnemonicException.MnemonicWordException, MnemonicException.MnemonicChecksumException { String[] wordsList; - wordsList = mnemonicPhrase.split(" "); + + mnemonicPhrase = mnemonicPhrase.replaceAll("\\s+", " "); + wordsList = mnemonicPhrase.split("\\s+"); return toEntropy(Arrays.asList(wordsList)); } diff --git a/function/build.gradle b/function/build.gradle index a62ab4a6..f885924b 100644 --- a/function/build.gradle +++ b/function/build.gradle @@ -1,5 +1,6 @@ dependencies { api project(':core') + api project(':hd-wallet') } publishing { diff --git a/function/src/main/java/com/bloxbean/cardano/client/function/TxBuilderContext.java b/function/src/main/java/com/bloxbean/cardano/client/function/TxBuilderContext.java index f4d3b4cf..3fa54d02 100644 --- a/function/src/main/java/com/bloxbean/cardano/client/function/TxBuilderContext.java +++ b/function/src/main/java/com/bloxbean/cardano/client/function/TxBuilderContext.java @@ -267,9 +267,7 @@ public static TxBuilderContext init(UtxoSupplier utxoSupplier, ProtocolParamsSup * @throws com.bloxbean.cardano.client.function.exception.TxBuildException if exception during transaction build */ public Transaction build(TxBuilder txBuilder) { - Transaction transaction = new Transaction(); - transaction.setEra(getSerializationEra()); - txBuilder.apply(this, transaction); + Transaction transaction = buildTransaction(txBuilder); clearTempStates(); return transaction; } @@ -282,8 +280,10 @@ public Transaction build(TxBuilder txBuilder) { * @throws com.bloxbean.cardano.client.function.exception.TxBuildException if exception during transaction build */ public Transaction buildAndSign(TxBuilder txBuilder, TxSigner signer) { - Transaction transaction = build(txBuilder); - return signer.sign(transaction); + Transaction transaction = buildTransaction(txBuilder); + Transaction signedTransaction = signer.sign(this, transaction); + clearTempStates(); + return signedTransaction; } /** @@ -297,6 +297,13 @@ public void build(Transaction transaction, TxBuilder txBuilder) { clearTempStates(); } + private Transaction buildTransaction(TxBuilder txBuilder) { + Transaction transaction = new Transaction(); + transaction.setEra(getSerializationEra()); + txBuilder.apply(this, transaction); + return transaction; + } + private void clearTempStates() { clearMintMultiAssets(); clearUtxos(); diff --git a/function/src/main/java/com/bloxbean/cardano/client/function/TxSigner.java b/function/src/main/java/com/bloxbean/cardano/client/function/TxSigner.java index 5a250f41..39fdc1a0 100644 --- a/function/src/main/java/com/bloxbean/cardano/client/function/TxSigner.java +++ b/function/src/main/java/com/bloxbean/cardano/client/function/TxSigner.java @@ -12,10 +12,11 @@ public interface TxSigner { /** * Apply this function to sign a transaction * - * @param transaction + * @param context {@link TxBuilderContext} + * @param transaction {@link Transaction} to sign * @return a signed transaction */ - Transaction sign(Transaction transaction); + Transaction sign(TxBuilderContext context, Transaction transaction); /** * Returns a composed function that first applies this function to @@ -29,6 +30,6 @@ public interface TxSigner { */ default TxSigner andThen(TxSigner after) { Objects.requireNonNull(after); - return (transaction) -> after.sign(sign(transaction)); + return (context, transaction) -> after.sign(context, sign(context, transaction)); } } diff --git a/function/src/main/java/com/bloxbean/cardano/client/function/helper/SignerProviders.java b/function/src/main/java/com/bloxbean/cardano/client/function/helper/SignerProviders.java index 5c98bde4..cc242cb1 100644 --- a/function/src/main/java/com/bloxbean/cardano/client/function/helper/SignerProviders.java +++ b/function/src/main/java/com/bloxbean/cardano/client/function/helper/SignerProviders.java @@ -7,6 +7,10 @@ import com.bloxbean.cardano.client.transaction.TransactionSigner; import com.bloxbean.cardano.client.transaction.spec.Policy; import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; + +import java.util.stream.Collectors; /** * Provides helper methods to get TxSigner function to sign a {@link Transaction} object @@ -20,7 +24,7 @@ public class SignerProviders { */ public static TxSigner signerFrom(Account... signers) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Account signer : signers) { outputTxn = signer.sign(outputTxn); @@ -30,6 +34,22 @@ public static TxSigner signerFrom(Account... signers) { }; } + /** + * Function to sign a transaction with a wallet + * + * @param wallet wallet to sign the transaction + * @return TxSigner function which returns a Transaction object with witnesses when invoked + */ + public static TxSigner signerFrom(Wallet wallet) { + return (context, transaction) -> { + var utxos = context.getUtxos() + .stream().filter(utxo -> utxo instanceof WalletUtxo) + .map(utxo -> (WalletUtxo) utxo) + .collect(Collectors.toSet()); + return wallet.sign(transaction, utxos); + }; + } + /** * Function to sign a transaction with one or more SecretKey * @param secretKeys secret keys to sign the transaction @@ -37,7 +57,7 @@ public static TxSigner signerFrom(Account... signers) { */ public static TxSigner signerFrom(SecretKey... secretKeys) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (SecretKey sk : secretKeys) { outputTxn = TransactionSigner.INSTANCE.sign(outputTxn, sk); @@ -54,7 +74,7 @@ public static TxSigner signerFrom(SecretKey... secretKeys) { */ public static TxSigner signerFrom(Policy... policies) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Policy policy : policies) { for (SecretKey sk : policy.getPolicyKeys()) { @@ -73,7 +93,7 @@ public static TxSigner signerFrom(Policy... policies) { */ public static TxSigner signerFrom(HdKeyPair... hdKeyPairs) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (HdKeyPair hdKeyPair : hdKeyPairs) { outputTxn = TransactionSigner.INSTANCE.sign(outputTxn, hdKeyPair); @@ -90,7 +110,7 @@ public static TxSigner signerFrom(HdKeyPair... hdKeyPairs) { */ public static TxSigner stakeKeySignerFrom(Account... signers) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Account signer : signers) { outputTxn = signer.signWithStakeKey(outputTxn); @@ -106,7 +126,7 @@ public static TxSigner stakeKeySignerFrom(Account... signers) { * @return TxSigner function which returns a Transaction object with witnesses when invoked */ public static TxSigner drepKeySignerFrom(Account... signers) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Account signer : signers) { outputTxn = signer.signWithDRepKey(outputTxn); @@ -116,6 +136,15 @@ public static TxSigner drepKeySignerFrom(Account... signers) { }; } + public static TxSigner stakeKeySignerFrom(Wallet... wallets) { + return (context, transaction) -> { + Transaction outputTxn = transaction; + for (Wallet wallet : wallets) + outputTxn = wallet.signWithStakeKey(outputTxn); + return outputTxn; + }; + } + //TODO -- Add Integration test /** * Function to sign a transaction with one or more Committee Cold key(s) of Account(s) @@ -123,7 +152,7 @@ public static TxSigner drepKeySignerFrom(Account... signers) { * @return TxSigner function which returns a Transaction object with witnesses when invoked */ public static TxSigner committeeColdKeySignerFrom(Account... signers) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Account signer : signers) { outputTxn = signer.signWithCommitteeColdKey(outputTxn); @@ -140,7 +169,7 @@ public static TxSigner committeeColdKeySignerFrom(Account... signers) { * @return TxSigner function which returns a Transaction object with witnesses when invoked */ public static TxSigner committeeHotKeySignerFrom(Account... signers) { - return transaction -> { + return (context, transaction) -> { Transaction outputTxn = transaction; for (Account signer : signers) { outputTxn = signer.signWithCommitteeHotKey(outputTxn); diff --git a/function/src/test/java/com/bloxbean/cardano/client/function/helper/SignerProvidersTest.java b/function/src/test/java/com/bloxbean/cardano/client/function/helper/SignerProvidersTest.java index 525536dc..a411c53a 100644 --- a/function/src/test/java/com/bloxbean/cardano/client/function/helper/SignerProvidersTest.java +++ b/function/src/test/java/com/bloxbean/cardano/client/function/helper/SignerProvidersTest.java @@ -25,7 +25,7 @@ void signerFromAccounts() throws Exception { Account account2 = new Account(Networks.testnet()); Transaction signedTxn = SignerProviders.signerFrom(account1, account2) - .sign(buildTransaction()); + .sign(null, buildTransaction()); assertThat(signedTxn.getWitnessSet().getVkeyWitnesses()).hasSize(2); } @@ -37,7 +37,7 @@ void signerFromSecretKey() throws Exception { SecretKey sk3 = KeyGenUtil.generateKey().getSkey(); Transaction signedTxn = SignerProviders.signerFrom(sk1, sk2, sk3) - .sign(buildTransaction()); + .sign(null, buildTransaction()); assertThat(signedTxn.getWitnessSet().getVkeyWitnesses()).hasSize(3); } @@ -48,7 +48,7 @@ void signerFromPolicies() throws Exception { Policy policy2 = PolicyUtil.createMultiSigScriptAllPolicy("2", 4); Transaction signedTxn = SignerProviders.signerFrom(policy1, policy2) - .sign(buildTransaction()); + .sign(null, buildTransaction()); assertThat(signedTxn.getWitnessSet().getVkeyWitnesses()).hasSize(7); } @@ -60,7 +60,7 @@ void signerFromHdKeyPairs() throws Exception { Transaction signedTxn = SignerProviders.signerFrom(account1.stakeHdKeyPair(), account2.stakeHdKeyPair()) .andThen(SignerProviders.signerFrom(account1)) - .sign(buildTransaction()); + .sign(null, buildTransaction()); assertThat(signedTxn.getWitnessSet().getVkeyWitnesses()).hasSize(3); } @@ -72,7 +72,7 @@ void signerFromAccountStakeKeys() throws Exception { Transaction signedTxn = SignerProviders.stakeKeySignerFrom(account1, account2) .andThen(SignerProviders.signerFrom(account1)) - .sign(buildTransaction()); + .sign(null, buildTransaction()); assertThat(signedTxn.getWitnessSet().getVkeyWitnesses()).hasSize(3); } diff --git a/hd-wallet/build.gradle b/hd-wallet/build.gradle new file mode 100644 index 00000000..8900a927 --- /dev/null +++ b/hd-wallet/build.gradle @@ -0,0 +1,29 @@ +dependencies { + api project(':core-api') + api project(':core') + api project(':common') + api project(':crypto') + api project(':backend') + implementation(libs.bouncycastle.bcprov) + + integrationTestImplementation(libs.slf4j.reload4j) + integrationTestImplementation(libs.aiken.java.binding) + integrationTestImplementation project(':') + integrationTestImplementation project(':backend-modules:blockfrost') + integrationTestImplementation project(':backend-modules:koios') + integrationTestImplementation project(':backend-modules:ogmios') + integrationTestImplementation project(':backend-modules:ogmios') + + integrationTestAnnotationProcessor project(':annotation-processor') +} + +publishing { + publications { + mavenJava(MavenPublication) { + pom { + name = 'Cardano Client HD Wallet' + description = 'Cardano Client Lib - HD Wallet Integration' + } + } + } +} diff --git a/hd-wallet/specification.md b/hd-wallet/specification.md new file mode 100644 index 00000000..04f27a0c --- /dev/null +++ b/hd-wallet/specification.md @@ -0,0 +1,25 @@ +# HD Wallet integration specification + +## Motivation + +Hierarchical deterministic wallets are a common practice in blockchains like Bitcoin and Cardano. +The idea behind that is to derive multiple keys (private and public) and addresses from a master key. +The advantage in contrast to individual addresses is that these keys/addresses are linked together through the master key. +Thus it is possible to maintain privacy due to changing public addresses frequently. +Otherwise one could track users through various transactions. + +Therefore it must be possible for users to use this concept via an easy-to-use API within this library. + +## Implementation + +- HDWallet Class + - Wrapper for Account - Deriving new Accounts with one Mnemonic + - Scanning strategy -> 20 consecutive empty addresses + - First Interface Approach [HDWalletInterface.java](src/main/java/com/bloxbean/cardano/hdwallet/HDWalletInterface.java) +- Transaction Building - extend QuickTxBuilder to use HDWallet + - Getting UTXOs to pay certain amount + - Getting Signers for the respective UTXOs spend in the transaction + - Build and Sign Transactions from HDWallet +- Minting Assets to a specific address +- Coin Selection Strategies + - Support common UTXO SelectionStrategys (Biggest, sequential, ...) \ No newline at end of file diff --git a/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBaseIT.java b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBaseIT.java new file mode 100644 index 00000000..3d9ca406 --- /dev/null +++ b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBaseIT.java @@ -0,0 +1,119 @@ +package com.bloxbean.cardano.hdwallet; + +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.backend.api.BackendService; +import com.bloxbean.cardano.client.backend.api.DefaultUtxoSupplier; +import com.bloxbean.cardano.client.backend.blockfrost.common.Constants; +import com.bloxbean.cardano.client.backend.blockfrost.service.BFBackendService; +import com.bloxbean.cardano.client.backend.koios.KoiosBackendService; +import com.bloxbean.cardano.client.backend.model.TransactionContent; +import com.bloxbean.cardano.client.util.JsonUtil; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +public class QuickTxBaseIT { + public static final String DEVKIT_ADMIN_BASE_URL = "http://localhost:10000/"; + protected static String BLOCKFROST = "blockfrost"; + protected static String KOIOS = "koios"; + protected static String DEVKIT = "devkit"; + protected static String backendType = DEVKIT; + + public static BackendService getBackendService() { + if (BLOCKFROST.equals(backendType)) { + String bfProjectId = System.getProperty("BF_PROJECT_ID"); + if (bfProjectId == null || bfProjectId.isEmpty()) { + bfProjectId = System.getenv("BF_PROJECT_ID"); + } + + return new BFBackendService(Constants.BLOCKFROST_PREPROD_URL, bfProjectId); + } else if (KOIOS.equals(backendType)) { + return new KoiosBackendService(com.bloxbean.cardano.client.backend.koios.Constants.KOIOS_PREPROD_URL); + } else if (DEVKIT.equals(backendType)) { + return new BFBackendService("http://localhost:8080/api/v1/", "Dummy"); + } else + return null; + } + + public static UtxoSupplier getUTXOSupplier() { + return new DefaultUtxoSupplier(getBackendService().getUtxoService()); + } + + protected static void topUpFund(String address, long adaAmount) { + try { + // URL to the top-up API + String url = DEVKIT_ADMIN_BASE_URL + "local-cluster/api/addresses/topup"; + URL obj = new URL(url); + HttpURLConnection connection = (HttpURLConnection) obj.openConnection(); + + // Set request method to POST + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json; utf-8"); + connection.setRequestProperty("Accept", "application/json"); + connection.setDoOutput(true); + + // Create JSON payload + String jsonInputString = String.format("{\"address\": \"%s\", \"adaAmount\": %d}", address, adaAmount); + + // Send the request + try (OutputStream os = connection.getOutputStream()) { + byte[] input = jsonInputString.getBytes(StandardCharsets.UTF_8); + os.write(input, 0, input.length); + } + + // Check the response code + int responseCode = connection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + System.out.println("Funds topped up successfully."); + } else { + System.out.println("Failed to top up funds. Response code: " + responseCode); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void waitForTransaction(Result result) { + try { + if (result.isSuccessful()) { //Wait for transaction to be mined + int count = 0; + while (count < 60) { + Result txnResult = getBackendService().getTransactionService().getTransaction(result.getValue()); + if (txnResult.isSuccessful()) { + System.out.println(JsonUtil.getPrettyJson(txnResult.getValue())); + break; + } else { + System.out.println("Waiting for transaction to be mined ...."); + } + + count++; + Thread.sleep(2000); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + protected void checkIfUtxoAvailable(String txHash, String address) { + Optional utxo = Optional.empty(); + int count = 0; + while (utxo.isEmpty()) { + if (count++ >= 20) + break; + List utxos = new DefaultUtxoSupplier(getBackendService().getUtxoService()).getAll(address); + utxo = utxos.stream().filter(u -> u.getTxHash().equals(txHash)) + .findFirst(); + System.out.println("Try to get new output... txhash: " + txHash); + try { + Thread.sleep(1000); + } catch (Exception e) {} + } + } +} diff --git a/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBuilderIT.java b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBuilderIT.java new file mode 100644 index 00000000..ccd0055d --- /dev/null +++ b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/QuickTxBuilderIT.java @@ -0,0 +1,208 @@ +package com.bloxbean.cardano.hdwallet; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.api.util.PolicyUtil; +import com.bloxbean.cardano.client.backend.api.BackendService; +import com.bloxbean.cardano.client.cip.cip20.MessageMetadata; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.exception.CborSerializationException; +import com.bloxbean.cardano.client.function.helper.SignerProviders; +import com.bloxbean.cardano.client.metadata.Metadata; +import com.bloxbean.cardano.client.metadata.MetadataBuilder; +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.bloxbean.cardano.client.transaction.spec.Asset; +import com.bloxbean.cardano.client.transaction.spec.Policy; +import com.bloxbean.cardano.client.util.JsonUtil; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; +import com.bloxbean.cardano.hdwallet.supplier.DefaultWalletUtxoSupplier; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class QuickTxBuilderIT extends QuickTxBaseIT { + + BackendService backendService; + UtxoSupplier utxoSupplier; + DefaultWalletUtxoSupplier walletUtxoSupplier; + Wallet wallet1; + Wallet wallet2; + + static Account topupAccount; + + @BeforeAll + static void beforeAll() { + String topupAccountMnemonic = "weapon news intact viable rigid hope ginger defy remove enemy dog volume belt clay shuffle angle crunch eye end asthma arctic sphere arm limit"; + topupAccount = new Account(Networks.testnet(), topupAccountMnemonic); + + topUpFund(topupAccount.baseAddress(), 100000); + topUpFund("addr_test1qz5t8wq55e09usmh07ymxry8atzwxwt2nwwzfngg6esffxvw2pfap6uqmkj3n6zmlrsgz397md2gt7yqs5p255uygaesx608y5", 5); + System.out.println("Topup address : " + topupAccount.baseAddress()); + } + + @BeforeEach + void setup() { + backendService = getBackendService(); + utxoSupplier = getUTXOSupplier(); + + String wallet1Mnemonic = "clog book honey force cricket stamp until seed minimum margin denial kind volume undo simple federal then jealous solid legal crucial crazy acoustic thank"; + wallet1 = new Wallet(Networks.testnet(), wallet1Mnemonic); + String wallet2Mnemonic = "theme orphan remind output arrive lobster decorate ten gap piece casual distance attend total blast dilemma damp punch pride file limit soldier plug canoe"; + wallet2 = new Wallet(Networks.testnet(), wallet2Mnemonic); + + walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + } + + @Test + void simplePayment() { + Metadata metadata = MetadataBuilder.createMetadata(); + metadata.put(BigInteger.valueOf(100), "This is first metadata"); + metadata.putNegative(200, -900); + + //topup wallet + splitPaymentBetweenAddress(topupAccount, wallet1, 20, Double.valueOf(50000)); + + + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + + Tx tx = new Tx() + .payToAddress(wallet2.getBaseAddress(0).getAddress(), Amount.ada(50000)) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector(txn -> { + System.out.println(JsonUtil.getPrettyJson(txn)); + }) + .complete(); + + System.out.println(result); + assertTrue(result.isSuccessful()); + waitForTransaction(result); + + checkIfUtxoAvailable(result.getValue(), wallet2.getBaseAddress(0).getAddress()); + } + + @Test + void simplePayment_withIndexesToScan() { + String mnemonic = "buzz sentence empty coffee manage grid claw street misery deputy direct seek tortoise wedding stay twist crew august omit taste expect obscure abandon iron"; + Wallet wallet = new Wallet(Networks.testnet(), mnemonic); + wallet.setIndexesToScan(new int[]{5, 30, 45}); + + //topup index 5, 45 + topUpFund(wallet.getBaseAddressString(5), 5); + topUpFund(wallet.getBaseAddressString(45), 15); + + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + + Tx tx = new Tx() + .payToAddress(wallet2.getBaseAddress(0).getAddress(), Amount.ada(18)) + .from(wallet); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet)) + .withTxInspector(txn -> { + System.out.println(JsonUtil.getPrettyJson(txn)); + }) + .complete(); + + System.out.println(result); + assertTrue(result.isSuccessful()); + waitForTransaction(result); + + checkIfUtxoAvailable(result.getValue(), wallet2.getBaseAddress(0).getAddress()); + } + + @Test + void minting() throws CborSerializationException { + Policy policy = PolicyUtil.createMultiSigScriptAtLeastPolicy("test_policy", 1, 1); + String assetName = "MyAsset"; + BigInteger qty = BigInteger.valueOf(1000); + + Tx tx = new Tx() + .mintAssets(policy.getPolicyScript(), new Asset(assetName, qty), wallet1.getBaseAddress(0).getAddress()) + .attachMetadata(MessageMetadata.create().add("Minting tx")) + .from(wallet1); + + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withSigner(SignerProviders.signerFrom(policy)) + .complete(); + + System.out.println(result); + assertTrue(result.isSuccessful()); + waitForTransaction(result); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddress(0).getAddress()); + } + + @Test + void utxoTest() { + List utxos = walletUtxoSupplier.getAll(); + Map amountMap = new HashMap<>(); + for (Utxo utxo : utxos) { + int totalAmount = 0; + if (amountMap.containsKey(utxo.getAddress())) { + int amount = amountMap.get(utxo.getAddress()); + System.out.println(utxo.getAmount().get(0)); + totalAmount = amount + utxo.getAmount().get(0).getQuantity().intValue(); + } + amountMap.put(utxo.getAddress(), totalAmount); + } + + assertTrue(!utxos.isEmpty()); + } + + void splitPaymentBetweenAddress(Account topupAccount, Wallet receiverWallet, int totalAddresses, Double adaAmount) { + // Create an amount array with no of totalAddresses with random distribution of split amounts + Double[] amounts = new Double[totalAddresses]; + Double remainingAmount = adaAmount; + Random rand = new Random(); + + for (int i = 0; i < totalAddresses - 1; i++) { + Double randomAmount = Double.valueOf(rand.nextInt(remainingAmount.intValue())); + amounts[i] = randomAmount; + remainingAmount = remainingAmount - randomAmount; + } + amounts[totalAddresses - 1] = remainingAmount; + + String[] addresses = new String[totalAddresses]; + Random random = new Random(); + int currentIndex = 0; + + for (int i = 0; i < totalAddresses; i++) { + addresses[i] = receiverWallet.getBaseAddressString(currentIndex); + currentIndex += random.nextInt(20) + 1; + } + + Tx tx = new Tx(); + for (int i = 0; i < addresses.length; i++) { + tx.payToAddress(addresses[i], Amount.ada(amounts[i])); + } + + tx.from(topupAccount.baseAddress()); + + var result = new QuickTxBuilder(backendService) + .compose(tx) + .withSigner(SignerProviders.signerFrom(topupAccount)) + .completeAndWait(); + + System.out.println(result); + } +} diff --git a/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/StakeTxIT.java b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/StakeTxIT.java new file mode 100644 index 00000000..174a5d9f --- /dev/null +++ b/hd-wallet/src/it/java/com/bloxbean/cardano/hdwallet/StakeTxIT.java @@ -0,0 +1,312 @@ +package com.bloxbean.cardano.hdwallet; + +import com.bloxbean.cardano.aiken.AikenTransactionEvaluator; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.backend.api.BackendService; +import com.bloxbean.cardano.client.backend.api.DefaultProtocolParamsSupplier; +import com.bloxbean.cardano.client.cip.cip20.MessageMetadata; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.function.helper.SignerProviders; +import com.bloxbean.cardano.client.plutus.blueprint.PlutusBlueprintUtil; +import com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion; +import com.bloxbean.cardano.client.plutus.spec.BigIntPlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusScript; +import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.ScriptTx; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.bloxbean.cardano.client.util.JsonUtil; +import com.bloxbean.cardano.hdwallet.supplier.DefaultWalletUtxoSupplier; +import com.bloxbean.cardano.hdwallet.supplier.WalletUtxoSupplier; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class StakeTxIT extends QuickTxBaseIT { + static BackendService backendService; + static UtxoSupplier utxoSupplier; + static WalletUtxoSupplier walletUtxoSupplier; + static Wallet wallet1; + static Wallet wallet2; + + static String poolId; + static ProtocolParamsSupplier protocolParamsSupplier; + + static String aikenCompiledCode1 = "581801000032223253330043370e00290010a4c2c6eb40095cd1"; //redeemer = 1 + static PlutusScript plutusScript1 = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(aikenCompiledCode1, PlutusVersion.v2); + + static String aikenCompileCode2 = "581801000032223253330043370e00290020a4c2c6eb40095cd1"; //redeemer = 2 + static PlutusScript plutusScript2 = PlutusBlueprintUtil.getPlutusScriptFromCompiledCode(aikenCompileCode2, PlutusVersion.v2); + + static String scriptStakeAddress1 = AddressProvider.getRewardAddress(plutusScript1, Networks.testnet()).toBech32(); + static String scriptStakeAddress2 = AddressProvider.getRewardAddress(plutusScript2, Networks.testnet()).toBech32(); + + static QuickTxBuilder quickTxBuilder; + static ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeAll + static void beforeAll() { + backendService = getBackendService(); + utxoSupplier = getUTXOSupplier(); + + protocolParamsSupplier = new DefaultProtocolParamsSupplier(backendService.getEpochService()); + quickTxBuilder = new QuickTxBuilder(backendService); + + String wallet1Mnemonic = "clog book honey force cricket stamp until seed minimum margin denial kind volume undo simple federal then jealous solid legal crucial crazy acoustic thank"; + wallet1 = new Wallet(Networks.testnet(), wallet1Mnemonic); + String wallet2Mnemonic = "theme orphan remind output arrive lobster decorate ten gap piece casual distance attend total blast dilemma damp punch pride file limit soldier plug canoe"; + wallet2 = new Wallet(Networks.testnet(), wallet2Mnemonic); + + if (backendType.equals(DEVKIT)) { + poolId = "pool1wvqhvyrgwch4jq9aa84hc8q4kzvyq2z3xr6mpafkqmx9wce39zy"; + } else { + poolId = "pool1vqq4hdwrh442u97e2jh6k4xuscs3x5mqjjrn8daj36y7gt2rj85"; + } + + topUpFund(wallet1.getBaseAddressString(0), 10000L); + } + + @BeforeEach + void setup() { + } + + @Test + @Order(1) + void stakeAddressRegistration() { + //De-register all stake addresses if required + _deRegisterStakeKeys(); + + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Tx tx = new Tx() + .payToAddress(wallet1.getBaseAddressString(1), Amount.ada(1.5)) + .payToAddress(wallet2.getBaseAddressString(1), Amount.ada(2.5)) + .payToAddress(wallet1.getBaseAddressString(0), Amount.ada(4.3)) + .registerStakeAddress(wallet2) + .registerStakeAddress(wallet1) + .attachMetadata(MessageMetadata.create().add("This is a stake registration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(1)); + } + + @Test + @Order(2) + void stakeAddressDeRegistration() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); // TODO WalletUTXOSupplier only works with one wallet - Is it a problem? + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Tx tx = new Tx() + .payToAddress(wallet1.getBaseAddressString(1), Amount.ada(1.5)) + .payToAddress(wallet1.getBaseAddressString(0), Amount.ada(4.0)) + .deregisterStakeAddress(wallet1) + .attachMetadata(MessageMetadata.create().add("This is a stake registration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withSigner(SignerProviders.stakeKeySignerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(1)); + } + + @Test + @Order(3) + void stakeAddressRegistration_onlyRegistration() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Tx tx = new Tx() + .registerStakeAddress(wallet1) + .attachMetadata(MessageMetadata.create().add("This is a stake registration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + @Test + @Order(4) + void stakeAddressDeRegistration_onlyRegistration() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Tx tx = new Tx() + .deregisterStakeAddress(wallet1) + .attachMetadata(MessageMetadata.create().add("This is a stake deregistration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withSigner(SignerProviders.stakeKeySignerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + @Test + @Order(5) + void scriptStakeAddress_registration() { +// deregisterScriptsStakeKeys(); + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + Tx tx = new Tx() + .registerStakeAddress(scriptStakeAddress1) + .attachMetadata(MessageMetadata.create().add("This is a script stake registration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + @Test + @Order(6) + void scriptStakeAddress_deRegistration() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + ScriptTx tx = new ScriptTx() + .deregisterStakeAddress(scriptStakeAddress1, BigIntPlutusData.of(1)) + .attachMetadata(MessageMetadata.create().add("This is a script stake address deregistration tx")) + .attachCertificateValidator(plutusScript1); + + Result result = quickTxBuilder.compose(tx) + .feePayer(wallet1.getBaseAddressString(0)) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .withTxEvaluator(!backendType.equals(BLOCKFROST) ? + new AikenTransactionEvaluator(utxoSupplier, protocolParamsSupplier) : null) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + @Test + @Order(9) + void stakeDelegation_scriptStakeKeys() { + registerScriptsStakeKeys(); + + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + + //Delegation + ScriptTx delegTx = new ScriptTx() + .delegateTo(new Address(scriptStakeAddress1), poolId, BigIntPlutusData.of(1)) + .attachMetadata(MessageMetadata.create().add("This is a delegation transaction")) + .attachCertificateValidator(plutusScript1); + + Result delgResult = quickTxBuilder.compose(delegTx) + .feePayer(wallet1.getBaseAddressString(0)) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxEvaluator(!backendType.equals(BLOCKFROST) ? + new AikenTransactionEvaluator(utxoSupplier, protocolParamsSupplier) : null) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(delgResult); + assertTrue(delgResult.isSuccessful()); + + checkIfUtxoAvailable(delgResult.getValue(), wallet1.getBaseAddressString(0)); + + deregisterScriptsStakeKeys(); + } + + private void registerScriptsStakeKeys() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + + //stake Registration + Tx tx = new Tx() + .registerStakeAddress(scriptStakeAddress1) + .attachMetadata(MessageMetadata.create().add("This is a script stake registration tx")) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + if (result.isSuccessful()) + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + private void deregisterScriptsStakeKeys() { + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService); + + //stake Registration + ScriptTx tx = new ScriptTx() + .deregisterStakeAddress(scriptStakeAddress1, BigIntPlutusData.of(1)) + .attachCertificateValidator(plutusScript1); + + Result result = quickTxBuilder.compose(tx) + .feePayer(wallet1.getBaseAddressString(0)) + .withSigner(SignerProviders.signerFrom(wallet1)) + .withTxEvaluator(!backendType.equals(BLOCKFROST) ? + new AikenTransactionEvaluator(utxoSupplier, protocolParamsSupplier) : null) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + assertTrue(result.isSuccessful()); + + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + } + + private Result _deRegisterStakeKeys() { + UtxoSupplier walletUtxoSupplier = new DefaultWalletUtxoSupplier(backendService.getUtxoService(), wallet1); + QuickTxBuilder quickTxBuilder = new QuickTxBuilder(backendService, walletUtxoSupplier); + + //stake Registration + Tx tx = new Tx() + .deregisterStakeAddress(wallet1) + .deregisterStakeAddress(wallet2) + .from(wallet1); + + Result result = quickTxBuilder.compose(tx) + .withSigner(SignerProviders.stakeKeySignerFrom(wallet1)) + .withSigner(SignerProviders.stakeKeySignerFrom(wallet2)) + .withTxInspector((txn) -> System.out.println(JsonUtil.getPrettyJson(txn))) + .completeAndWait(msg -> System.out.println(msg)); + + System.out.println(result); + if (result.isSuccessful()) + checkIfUtxoAvailable(result.getValue(), wallet1.getBaseAddressString(0)); + return result; + } +} diff --git a/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/Wallet.java b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/Wallet.java new file mode 100644 index 00000000..be02a7b9 --- /dev/null +++ b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/Wallet.java @@ -0,0 +1,298 @@ +package com.bloxbean.cardano.hdwallet; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.common.model.Network; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.crypto.MnemonicUtil; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyGenerator; +import com.bloxbean.cardano.client.crypto.bip32.HdKeyPair; +import com.bloxbean.cardano.client.crypto.bip39.MnemonicCode; +import com.bloxbean.cardano.client.crypto.bip39.MnemonicException; +import com.bloxbean.cardano.client.crypto.bip39.Words; +import com.bloxbean.cardano.client.crypto.cip1852.CIP1852; +import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; +import com.bloxbean.cardano.client.transaction.TransactionSigner; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import lombok.Setter; + +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * The Wallet class represents wallet with functionalities to manage accounts, addresses. + */ +public class Wallet { + + @Getter + private int account = 0; + @Getter + private final Network network; + @Getter + private final String mnemonic; + private String stakeAddress; + private Map cache; + private HdKeyPair rootKeys; + private HdKeyPair stakeKeys; + + @Getter + @Setter + private int[] indexesToScan; //If set, only scan these indexes and avoid gap limit during address scanning + + @Getter + @Setter + private int gapLimit = 20; //No of unused addresses to scan. + + public Wallet() { + this(Networks.mainnet()); + } + + public Wallet(Network network) { + this(network, Words.TWENTY_FOUR); + } + + public Wallet(Network network, Words noOfWords) { + this(network, noOfWords, 0); + } + + public Wallet(Network network, Words noOfWords, int account) { + this.network = network; + this.mnemonic = MnemonicUtil.generateNew(noOfWords); + this.account = account; + cache = new HashMap<>(); + } + + public Wallet(String mnemonic) { + this(Networks.mainnet(), mnemonic); + } + + public Wallet(Network network, String mnemonic) { + this(network,mnemonic, 0); + } + + public Wallet(Network network, String mnemonic, int account) { + this.network = network; + this.mnemonic = mnemonic; + this.account = account; + MnemonicUtil.validateMnemonic(this.mnemonic); + cache = new HashMap<>(); + } + + /** + * Get Enterprise address for current account. Account can be changed via the setter. + * @param index + * @return + */ + public Address getEntAddress(int index) { + return getEntAddress(this.account, index); + } + + /** + * Get Enterprise address for derivation path m/1852'/1815'/{account}'/0/{index} + * @param account + * @param index + * @return + */ + private Address getEntAddress(int account, int index) { + return getAccount(account, index).getEnterpriseAddress(); + } + + /** + * Get Baseaddress for current account. Account can be changed via the setter. + * @param index + * @return + */ + public Address getBaseAddress(int index) { + return getBaseAddress(this.account, index); + } + + /** + * Get Baseaddress for current account as String. Account can be changed via the setter. + * @param index + * @return + */ + public String getBaseAddressString(int index) { + return getBaseAddress(index).getAddress(); + } + + /** + * Get Baseaddress for derivationpath m/1852'/1815'/{account}'/0/{index} + * @param account + * @param index + * @return + */ + public Address getBaseAddress(int account, int index) { + return getAccount(account,index).getBaseAddress(); + } + + /** + * Returns the Account object for the index and current account. Account can be changed via the setter. + * @param index + * @return + */ + public Account getAccount(int index) { + return getAccount(this.account, index); + } + + /** + * Returns the Account object for the index and account. + * @param account + * @param index + * @return + */ + public Account getAccount(int account, int index) { + if(account != this.account) { + DerivationPath derivationPath = DerivationPath.createExternalAddressDerivationPathForAccount(account); + derivationPath.getIndex().setValue(index); + return new Account(this.network, this.mnemonic, derivationPath); + } else { + if(cache.containsKey(index)) { + return cache.get(index); + } else { + Account acc = new Account(this.network, this.mnemonic, index); + cache.put(index, acc); + return acc; + } + } + } + + /** + * Setting the current account for derivation path. + * Setting the account will reset the cache. + * @param account + */ + public void setAccount(int account) { + this.account = account; + // invalidating cache since it is only held for one account + cache = new HashMap<>(); + } + + /** + * Returns the RootkeyPair + * @return + */ + @JsonIgnore + public HdKeyPair getRootKeyPair() { + if(rootKeys == null) { + HdKeyGenerator hdKeyGenerator = new HdKeyGenerator(); + try { + byte[] entropy = MnemonicCode.INSTANCE.toEntropy(this.mnemonic); + rootKeys = hdKeyGenerator.getRootKeyPairFromEntropy(entropy); + } catch (MnemonicException.MnemonicLengthException | MnemonicException.MnemonicWordException | + MnemonicException.MnemonicChecksumException e) { + throw new WalletException("Unable to derive root key pair", e); + } + } + return rootKeys; + } + + /** + * Finds needed signers within wallet and signs the transaction with each one + * @param txToSign + * @return signed Transaction + */ + public Transaction sign(Transaction txToSign, Set utxos) { + Map accountMap = utxos.stream() + .map(WalletUtxo::getDerivationPath) + .filter(Objects::nonNull) + .map(derivationPath -> getAccount( + derivationPath.getAccount().getValue(), + derivationPath.getIndex().getValue())) + .collect(Collectors.toMap( + Account::baseAddress, + Function.identity(), + (existing, replacement) -> existing)); // Handle duplicates if necessary + + var accounts = accountMap.values(); + + if(accounts.isEmpty()) + throw new WalletException("No signers found!"); + + for (Account signerAcc : accounts) + txToSign = signerAcc.sign(txToSign); + + return txToSign; + } + +// +// /** +// * Returns a list with signers needed for this transaction +// * +// * @param tx +// * @param utxoSupplier +// * @return +// */ +// public List getSignersForTransaction(Transaction tx, WalletUtxoSupplier utxoSupplier) { +// return getSignersForInputs(tx.getBody().getInputs(), utxoSupplier); +// } +// +// private List getSignersForInputs(List inputs, WalletUtxoSupplier utxoSupplier) { +// // searching for address to sign +// List signers = new ArrayList<>(); +// List remaining = new ArrayList<>(inputs); +// +// int index = 0; +// int emptyCounter = 0; +// while (!remaining.isEmpty() || emptyCounter >= INDEX_SEARCH_RANGE) { +// List utxos = utxoSupplier.getUtxosForAccountAndIndex(this.account, index); +// emptyCounter = utxos.isEmpty() ? emptyCounter + 1 : 0; +// +// for (Utxo utxo : utxos) { +// if(matchUtxoWithInputs(inputs, utxo, signers, index, remaining)) +// break; +// } +// index++; +// } +// return signers; +// } +// +// private boolean matchUtxoWithInputs(List inputs, Utxo utxo, List signers, int index, List remaining) { +// for (TransactionInput input : inputs) { +// if(utxo.getTxHash().equals(input.getTransactionId()) && utxo.getOutputIndex() == input.getIndex()) { +// var account = getAccountObject(index); +// var accNotFound = signers.stream() +// .noneMatch(acc -> account.baseAddress().equals(acc.baseAddress())); +// if (accNotFound) +// signers.add(getAccountObject(index)); +// remaining.remove(input); +// } +// } +// return remaining.isEmpty(); +// } + + /** + * Returns the stake address of the wallet. + * @return + */ + public String getStakeAddress() { + if (stakeAddress == null || stakeAddress.isEmpty()) { + HdKeyPair stakeKeyPair = getStakeKeyPair(); + Address address = AddressProvider.getRewardAddress(stakeKeyPair.getPublicKey(), network); + stakeAddress = address.toBech32(); + } + return stakeAddress; + } + + /** + * Signs the transaction with stake key from wallet. + * @param transaction + * @return + */ + public Transaction signWithStakeKey(Transaction transaction) { + return TransactionSigner.INSTANCE.sign(transaction, getStakeKeyPair()); + } + + private HdKeyPair getStakeKeyPair() { + if(stakeKeys == null) { + DerivationPath stakeDerivationPath = DerivationPath.createStakeAddressDerivationPathForAccount(this.account); + stakeKeys = new CIP1852().getKeyPairFromMnemonic(mnemonic, stakeDerivationPath); + } + return stakeKeys; + } + +} diff --git a/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/WalletException.java b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/WalletException.java new file mode 100644 index 00000000..4b5c1884 --- /dev/null +++ b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/WalletException.java @@ -0,0 +1,19 @@ +package com.bloxbean.cardano.hdwallet; + +public class WalletException extends RuntimeException { + + public WalletException() { + } + + public WalletException(String msg) { + super(msg); + } + + public WalletException(Throwable cause) { + super(cause); + } + + public WalletException(String msg, Throwable cause) { + super(msg, cause); + } +} diff --git a/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/model/WalletUtxo.java b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/model/WalletUtxo.java new file mode 100644 index 00000000..8c670063 --- /dev/null +++ b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/model/WalletUtxo.java @@ -0,0 +1,32 @@ +package com.bloxbean.cardano.hdwallet.model; + +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +public class WalletUtxo extends Utxo { + private DerivationPath derivationPath; + + public static WalletUtxo from(Utxo utxo) { + WalletUtxo walletUtxo = new WalletUtxo(); + walletUtxo.setTxHash(utxo.getTxHash()); + walletUtxo.setOutputIndex(utxo.getOutputIndex()); + walletUtxo.setAddress(utxo.getAddress()); + walletUtxo.setAmount(utxo.getAmount()); + walletUtxo.setDataHash(utxo.getDataHash()); + walletUtxo.setInlineDatum(utxo.getInlineDatum()); + walletUtxo.setReferenceScriptHash(utxo.getReferenceScriptHash()); + return walletUtxo; + } + +} diff --git a/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplier.java b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplier.java new file mode 100644 index 00000000..095573bc --- /dev/null +++ b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplier.java @@ -0,0 +1,117 @@ +package com.bloxbean.cardano.hdwallet.supplier; + +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.exception.ApiException; +import com.bloxbean.cardano.client.api.exception.ApiRuntimeException; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.backend.api.UtxoService; +import com.bloxbean.cardano.client.crypto.cip1852.DerivationPath; +import com.bloxbean.cardano.client.crypto.cip1852.Segment; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.hdwallet.WalletException; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +public class DefaultWalletUtxoSupplier implements WalletUtxoSupplier { + private final UtxoService utxoService; + @Setter + private Wallet wallet; + + public DefaultWalletUtxoSupplier(UtxoService utxoService, Wallet wallet) { + this.utxoService = utxoService; + this.wallet = wallet; + } + + @Override + public List getPage(String address, Integer nrOfItems, Integer page, OrderEnum order) { + return getAll(address); // todo get Page of utxo over multipe addresses - find a good way to aktually do something with page, nrOfItems and order + } + + @Override + public Optional getTxOutput(String txHash, int outputIndex) { + try { + var result = utxoService.getTxOutput(txHash, outputIndex); + return result != null && result.getValue() != null + ? Optional.of(WalletUtxo.from(result.getValue())) + : Optional.empty(); + } catch (ApiException e) { + throw new ApiRuntimeException(e); + } + } + + @Override + public List getAll(String address) { + checkIfWalletIsSet(); + return new ArrayList<>(getAll()); + } + + @Override + public List getAll() { + List utxos = new ArrayList<>(); + + if (wallet.getIndexesToScan() == null || wallet.getIndexesToScan().length == 0) { + int index = 0; + int noUtxoFound = 0; + + while (noUtxoFound < wallet.getGapLimit()) { + List utxoFromIndex = getUtxosForAccountAndIndex(wallet.getAccount(), index); + + utxos.addAll(utxoFromIndex); + noUtxoFound = utxoFromIndex.isEmpty() ? noUtxoFound + 1 : 0; + + index++; // increasing search index + } + } else { + for (int idx: wallet.getIndexesToScan()) { + List utxoFromIndex = getUtxosForAccountAndIndex(wallet.getAccount(), idx); + utxos.addAll(utxoFromIndex); + } + } + return utxos; + } + + @Override + public List getUtxosForAccountAndIndex(int account, int index) { + checkIfWalletIsSet(); + String address = wallet.getBaseAddress(account, index).getAddress(); + List utxos = new ArrayList<>(); + int page = 1; + while(true) { + Result> result = null; + try { + result = utxoService.getUtxos(address, UtxoSupplier.DEFAULT_NR_OF_ITEMS_TO_FETCH, page, OrderEnum.asc); + } catch (ApiException e) { + throw new ApiRuntimeException(e); + } + List utxoPage = result != null && result.getValue() != null ? result.getValue() : Collections.emptyList(); + + DerivationPath derivationPath = DerivationPath.createExternalAddressDerivationPathForAccount(account); + derivationPath.setIndex(Segment.builder().value(index).build()); + + var utxoList = utxoPage.stream().map(utxo -> { + var walletUtxo = WalletUtxo.from(utxo); + walletUtxo.setDerivationPath(derivationPath); + return walletUtxo; + }).collect(Collectors.toList()); + + utxos.addAll(utxoList); + if(utxoPage.size() < 100) + break; + page++; + } + return utxos; + } + + private void checkIfWalletIsSet() { + if(this.wallet == null) + throw new WalletException("Wallet has to be provided!"); + } +} diff --git a/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/WalletUtxoSupplier.java b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/WalletUtxoSupplier.java new file mode 100644 index 00000000..2aa5b260 --- /dev/null +++ b/hd-wallet/src/main/java/com/bloxbean/cardano/hdwallet/supplier/WalletUtxoSupplier.java @@ -0,0 +1,23 @@ +package com.bloxbean.cardano.hdwallet.supplier; + +import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; + +import java.util.List; + +public interface WalletUtxoSupplier extends UtxoSupplier { + + /** + * Returns all Utxos for provided wallets + * @return + */ + List getAll(); + + /** + * Returns all UTXOs for a specific address m/1852'/1815'/{account}'/0/{index} + * @param account + * @param index + * @return + */ + List getUtxosForAccountAndIndex(int account, int index); +} diff --git a/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/WalletTest.java b/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/WalletTest.java new file mode 100644 index 00000000..d123692e --- /dev/null +++ b/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/WalletTest.java @@ -0,0 +1,101 @@ +package com.bloxbean.cardano.hdwallet; + +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.crypto.bip39.Words; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class WalletTest { + + String phrase24W = "coconut you order found animal inform tent anxiety pepper aisle web horse source indicate eyebrow viable lawsuit speak dragon scheme among animal slogan exchange"; + + String baseAddress0 = "addr1qxsaa6czesrzwp45rd5flg86n5hnwhz5setqfyt39natwvsl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8ps7zwsra"; + String baseAddress1 = "addr1q93jwnn3hvgcuv02tqe08lpdkxxpmvapxgjxwewya47tqsgl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8ps4zthxn"; + String baseAddress2 = "addr1q8pr30ykyfa3pw6qkkun3dyyxsvftq3xukuyxdt58pxcpxgl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8ps4qp6cs"; + String baseAddress3 = "addr1qxa5pll82u8lqtzqjqhdr828medvfvezv4509nzyuhwt5aql5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8psy8jsmy"; + + String testnetBaseAddress0 = "addr_test1qzsaa6czesrzwp45rd5flg86n5hnwhz5setqfyt39natwvsl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8psa5ns0z"; + String testnetBaseAddress1 = "addr_test1qp3jwnn3hvgcuv02tqe08lpdkxxpmvapxgjxwewya47tqsgl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8psk5kh2v"; + String testnetBaseAddress2 = "addr_test1qrpr30ykyfa3pw6qkkun3dyyxsvftq3xukuyxdt58pxcpxgl5mr3vkp82y2kcwxxtu4zjcxvm80ttmx2hyeyjka4v8pskku650"; + + String entAddress0 = "addr1vxsaa6czesrzwp45rd5flg86n5hnwhz5setqfyt39natwvstf7k4n"; + String entAddress1 = "addr1v93jwnn3hvgcuv02tqe08lpdkxxpmvapxgjxwewya47tqsg7davae"; + String entAddress2 = "addr1v8pr30ykyfa3pw6qkkun3dyyxsvftq3xukuyxdt58pxcpxgvddj89"; + + String testnetEntAddress0 = "addr_test1vzsaa6czesrzwp45rd5flg86n5hnwhz5setqfyt39natwvssp226k"; + String testnetEntAddress1 = "addr_test1vp3jwnn3hvgcuv02tqe08lpdkxxpmvapxgjxwewya47tqsg99fsju"; + String testnetEntAddress2 = "addr_test1vrpr30ykyfa3pw6qkkun3dyyxsvftq3xukuyxdt58pxcpxgh9ewgq"; + + @Test + void generateMnemonic24w() { + Wallet hdWallet = new Wallet(Networks.testnet()); + String mnemonic = hdWallet.getMnemonic(); + assertEquals(24, mnemonic.split(" ").length); + } + + @Test + void generateMnemonic15w() { + Wallet hdWallet = new Wallet(Networks.testnet(), Words.FIFTEEN); + String mnemonic = hdWallet.getMnemonic(); + assertEquals(15, mnemonic.split(" ").length); + } + + @Test + void WalletAddressToAccountAddressTest() { + Wallet hdWallet = new Wallet(Networks.testnet()); + Address address = hdWallet.getBaseAddress(0); + Account a = new Account(hdWallet.getNetwork(), hdWallet.getMnemonic(), 0); + assertEquals(address.getAddress(), a.getBaseAddress().getAddress()); + } + + @Test + void testGetBaseAddressFromMnemonicIndex_0() { + Wallet wallet = new Wallet(Networks.mainnet(), phrase24W); + Assertions.assertEquals(baseAddress0, wallet.getBaseAddressString(0)); + Assertions.assertEquals(baseAddress1, wallet.getBaseAddressString(1)); + Assertions.assertEquals(baseAddress2, wallet.getBaseAddressString(2)); + Assertions.assertEquals(baseAddress3, wallet.getBaseAddressString(3)); + } + + @Test + void testGetBaseAddressFromMnemonicByNetworkInfoTestnet() { + Wallet wallet = new Wallet(Networks.testnet(), phrase24W); + Assertions.assertEquals(testnetBaseAddress0, wallet.getBaseAddressString(0)); + Assertions.assertEquals(testnetBaseAddress1, wallet.getBaseAddressString(1)); + Assertions.assertEquals(testnetBaseAddress2, wallet.getBaseAddressString(2)); + } + + @Test + void testGetEnterpriseAddressFromMnemonicIndex() { + Wallet wallet = new Wallet(Networks.mainnet(), phrase24W); + Assertions.assertEquals(entAddress0, wallet.getEntAddress(0).getAddress()); + Assertions.assertEquals(entAddress1, wallet.getEntAddress(1).getAddress()); + Assertions.assertEquals(entAddress2, wallet.getEntAddress(2).getAddress()); + } + + @Test + void testGetEnterpriseAddressFromMnemonicIndexByNetwork() { + Wallet wallet = new Wallet(Networks.testnet(), phrase24W); + Assertions.assertEquals(testnetEntAddress0, wallet.getEntAddress(0).getAddress()); + Assertions.assertEquals(testnetEntAddress1, wallet.getEntAddress(1).getAddress()); + Assertions.assertEquals(testnetEntAddress2, wallet.getEntAddress(2).getAddress()); + } + + @Test + void testGetPublicKeyBytesFromMnemonic() { + byte[] pubKey = new Wallet(phrase24W).getRootKeyPair().getPublicKey().getKeyData(); + Assertions.assertEquals(32, pubKey.length); + } + + @Test + void testGetPrivateKeyBytesFromMnemonic() { + byte[] pvtKey = new Wallet(phrase24W).getRootKeyPair().getPrivateKey().getBytes(); + Assertions.assertEquals(96, pvtKey.length); + } + + +} diff --git a/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplierTest.java b/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplierTest.java new file mode 100644 index 00000000..7aafeb80 --- /dev/null +++ b/hd-wallet/src/test/java/com/bloxbean/cardano/hdwallet/supplier/DefaultWalletUtxoSupplierTest.java @@ -0,0 +1,171 @@ +package com.bloxbean.cardano.hdwallet.supplier; + +import com.bloxbean.cardano.client.api.common.OrderEnum; +import com.bloxbean.cardano.client.api.exception.ApiException; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Result; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.backend.api.UtxoService; +import com.bloxbean.cardano.hdwallet.Wallet; +import com.bloxbean.cardano.hdwallet.model.WalletUtxo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigInteger; +import java.util.List; +import java.util.stream.Collectors; + +import static com.bloxbean.cardano.client.common.CardanoConstants.LOVELACE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultWalletUtxoSupplierTest { + + @Mock + private UtxoService utxoService; + + @BeforeEach + public void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void getAll() throws ApiException { + Wallet wallet = new Wallet(); + var addr1 = wallet.getAccount(3).baseAddress(); + var addr2 = wallet.getAccount(7).baseAddress(); + var addr3 = wallet.getAccount(25).baseAddress(); + var addr4 = wallet.getAccount(50).baseAddress(); + + DefaultWalletUtxoSupplier utxoSupplier = new DefaultWalletUtxoSupplier(utxoService, wallet); + + given(utxoService.getUtxos(addr1, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr1) + .txHash("tx1") + .outputIndex(0) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(100)) + .unit(LOVELACE) + .build())).build() + ))); + + given(utxoService.getUtxos(addr2, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr2) + .txHash("tx2") + .outputIndex(0) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(200)) + .unit(LOVELACE) + .build())).build() + ))); + given(utxoService.getUtxos(addr3, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr3) + .txHash("tx3") + .outputIndex(10) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(300)) + .unit(LOVELACE) + .build())).build() + ))); + given(utxoService.getUtxos(addr4, 40, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr4) + .txHash("tx4") + .outputIndex(4) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(400)) + .unit(LOVELACE) + .build())).build() + ))); + + + + List utxoList = utxoSupplier.getAll(); + assertThat(utxoList).hasSize(3); + assertThat(utxoList.stream().map(utxo -> utxo.getAddress()).collect(Collectors.toList())) + .contains(addr1, addr2, addr3) + .doesNotContain(addr4); + } + + @Test + void getAllWhenIndexesToScan() throws ApiException { + Wallet wallet = new Wallet(); + wallet.setIndexesToScan(new int[]{25, 50}); + var addr1 = wallet.getAccount(3).baseAddress(); + var addr2 = wallet.getAccount(7).baseAddress(); + var addr3 = wallet.getAccount(25).baseAddress(); + var addr4 = wallet.getAccount(50).baseAddress(); + + DefaultWalletUtxoSupplier utxoSupplier = new DefaultWalletUtxoSupplier(utxoService, wallet); + + given(utxoService.getUtxos(addr1, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr1) + .txHash("tx1") + .outputIndex(0) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(100)) + .unit(LOVELACE) + .build())).build() + ))); + + given(utxoService.getUtxos(addr2, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr2) + .txHash("tx2") + .outputIndex(0) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(200)) + .unit(LOVELACE) + .build())).build() + ))); + given(utxoService.getUtxos(addr3, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr3) + .txHash("tx3") + .outputIndex(10) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(300)) + .unit(LOVELACE) + .build())).build() + ))); + given(utxoService.getUtxos(addr4, 100, 1, OrderEnum.asc)) + .willReturn(Result.success("ok").withValue(List.of( + Utxo.builder() + .address(addr4) + .txHash("tx4") + .outputIndex(4) + .amount(List.of(Amount.builder() + .quantity(BigInteger.valueOf(400)) + .unit(LOVELACE) + .build())).build() + ))); + + + + List utxoList = utxoSupplier.getAll(); + assertThat(utxoList).hasSize(2); + assertThat(utxoList.stream().map(utxo -> utxo.getAddress()).collect(Collectors.toList())) + .contains(addr3, addr4) + .doesNotContain(addr1, addr2); + } + +} diff --git a/quicktx/build.gradle b/quicktx/build.gradle index a9cca7b1..1001b3fd 100644 --- a/quicktx/build.gradle +++ b/quicktx/build.gradle @@ -2,6 +2,7 @@ dependencies { api project(':core') api project(':function') api project(':backend') + api project(':hd-wallet') integrationTestImplementation(libs.slf4j.reload4j) integrationTestImplementation(libs.aiken.java.binding) diff --git a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/AbstractTx.java b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/AbstractTx.java index 4eec4fbb..3ed8e931 100644 --- a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/AbstractTx.java +++ b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/AbstractTx.java @@ -20,6 +20,7 @@ import com.bloxbean.cardano.client.util.Tuple; import lombok.AllArgsConstructor; import lombok.Getter; +import com.bloxbean.cardano.hdwallet.Wallet; import lombok.NonNull; import lombok.SneakyThrows; @@ -482,6 +483,8 @@ protected void addDepositRefundContext(List _depositRefund */ protected abstract String getFromAddress(); + protected abstract Wallet getFromWallet(); + /** * Perform pre Tx evaluation action. This is called before Script evaluation if any * @param transaction diff --git a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/QuickTxBuilder.java b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/QuickTxBuilder.java index d795e075..bb58ecb6 100644 --- a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/QuickTxBuilder.java +++ b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/QuickTxBuilder.java @@ -22,6 +22,8 @@ import com.bloxbean.cardano.client.transaction.spec.Transaction; import com.bloxbean.cardano.client.transaction.spec.TransactionInput; import com.bloxbean.cardano.client.util.JsonUtil; +import com.bloxbean.cardano.client.util.Tuple; +import com.bloxbean.cardano.hdwallet.supplier.WalletUtxoSupplier; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -114,6 +116,18 @@ public QuickTxBuilder(BackendService backendService) { } } + /** + * Create a QuickTxBuilder instance with specified BackendService and UtxoSupplier. + * + * @param backendService backend service to get protocol params and submit transactions + * @param utxoSupplier utxo supplier to get utxos + */ + public QuickTxBuilder(BackendService backendService, UtxoSupplier utxoSupplier) { + this(utxoSupplier, + new DefaultProtocolParamsSupplier(backendService.getEpochService()), + new DefaultTransactionProcessor(backendService.getTransactionService())); + } + /** * Create TxContext for the given txs * @@ -225,6 +239,25 @@ public TxContext additionalSignersCount(int additionalSigners) { * @return Transaction */ public Transaction build() { + Tuple tuple = _build(); + return tuple._1.build(tuple._2); + } + + /** + * Build and sign transaction + * + * @return Transaction + */ + public Transaction buildAndSign() { + Tuple tuple = _build(); + + if (signers != null) + return tuple._1.buildAndSign(tuple._2, signers); + else + throw new IllegalStateException("No signers found"); + } + + private Tuple _build() { TxBuilder txBuilder = (context, txn) -> { }; boolean containsScriptTx = false; @@ -374,7 +407,8 @@ public Transaction build() { tx.postBalanceTx(transaction); })); } - return txBuilderContext.build(txBuilder); + + return new Tuple<>(txBuilderContext, txBuilder); } private int getTotalSigners() { @@ -385,19 +419,6 @@ private int getTotalSigners() { return totalSigners; } - /** - * Build and sign transaction - * - * @return Transaction - */ - public Transaction buildAndSign() { - Transaction transaction = build(); - if (signers != null) - transaction = signers.sign(transaction); - - return transaction; - } - private TxBuilder buildCollateralOutput(String feePayer) { if (collateralInputs != null && !collateralInputs.isEmpty()) { List collateralUtxos = collateralInputs.stream() @@ -437,6 +458,10 @@ public Result complete() { if (txList.length == 0) throw new TxBuildException("At least one tx is required"); + boolean txListContainsWallet = Arrays.stream(txList).anyMatch(abstractTx -> abstractTx.getFromWallet() != null); + if(txListContainsWallet && !(utxoSupplier instanceof WalletUtxoSupplier)) + throw new TxBuildException("Provide a WalletUtxoSupplier when using a sender wallet"); + Transaction transaction = buildAndSign(); if (txInspector != null) diff --git a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/ScriptTx.java b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/ScriptTx.java index aaf77827..80ce18fc 100644 --- a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/ScriptTx.java +++ b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/ScriptTx.java @@ -18,6 +18,7 @@ import com.bloxbean.cardano.client.transaction.spec.governance.actions.GovAction; import com.bloxbean.cardano.client.transaction.spec.governance.actions.GovActionId; import com.bloxbean.cardano.client.util.Tuple; +import com.bloxbean.cardano.hdwallet.Wallet; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NonNull; @@ -638,6 +639,11 @@ protected String getFromAddress() { return fromAddress; } + @Override + protected Wallet getFromWallet() { + return null; + } + void from(String address) { this.fromAddress = address; } diff --git a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/Tx.java b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/Tx.java index 2917dc27..1310f0ef 100644 --- a/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/Tx.java +++ b/quicktx/src/main/java/com/bloxbean/cardano/client/quicktx/Tx.java @@ -19,6 +19,7 @@ import com.bloxbean.cardano.client.transaction.spec.governance.actions.GovActionId; import com.bloxbean.cardano.client.transaction.spec.script.NativeScript; import com.bloxbean.cardano.client.util.Tuple; +import com.bloxbean.cardano.hdwallet.Wallet; import lombok.NonNull; import java.math.BigInteger; @@ -32,6 +33,7 @@ public class Tx extends AbstractTx { private String sender; protected boolean senderAdded = false; + private Wallet senderWallet; /** * Create Tx @@ -121,6 +123,16 @@ public Tx from(String sender) { return this; } + public Tx from(Wallet sender) { + verifySenderNotExists(); + this.senderWallet = sender; + // TODO sender is not used in this scenarios, but it must be set to avoid breaking other things. + this.sender = this.senderWallet.getBaseAddress(0).getAddress(); // TODO - is it clever to use the first address as sender here? + this.changeAddress = this.sender; + this.senderAdded = true; + return this; + } + /** * Create Tx with given utxos as inputs. * @param utxos List of utxos @@ -151,6 +163,11 @@ public Tx registerStakeAddress(@NonNull String address) { return this; } + public Tx registerStakeAddress(@NonNull Wallet wallet) { + stakeTx.registerStakeAddress(new Address(wallet.getStakeAddress())); + return this; + } + /** * Register stake address * @param address address to register. Address should have delegation credential. So it should be a base address or stake address. @@ -181,6 +198,11 @@ public Tx deregisterStakeAddress(@NonNull Address address) { return this; } + public Tx deregisterStakeAddress(@NonNull Wallet wallet) { + stakeTx.deregisterStakeAddress(new Address(wallet.getStakeAddress()), null, null); + return this; + } + /** * De-register stake address. The key deposit will be refunded to the refund address. * @param address address to de-register. Address should have delegation credential. So it should be a base address or stake address. @@ -214,6 +236,11 @@ public Tx delegateTo(@NonNull String address, @NonNull String poolId) { return this; } + public Tx delegateTo(@NonNull Wallet wallet, @NonNull String poolId) { + stakeTx.delegateTo(new Address(wallet.getStakeAddress()), poolId, null); + return this; + } + /** * Delegate stake address to a stake pool * @param address address to delegate. Address should have delegation credential. So it should be a base address or stake address. @@ -475,6 +502,8 @@ protected String getChangeAddress() { return changeAddress; else if (sender != null) return sender; + else if (senderWallet != null) + return senderWallet.getBaseAddress(0).getAddress(); // TODO - Change address to a new index?? else throw new TxBuildException("No change address. " + "Please define at least one of sender address or sender account or change address"); @@ -488,6 +517,14 @@ protected String getFromAddress() { throw new TxBuildException("No sender address or sender account defined"); } + @Override + protected Wallet getFromWallet() { + if(senderWallet != null) + return senderWallet; + else + return null; + } + @Override protected void postBalanceTx(Transaction transaction) { diff --git a/settings.gradle b/settings.gradle index e6a2539d..0e631709 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,6 +13,7 @@ include 'core' include 'function' include 'quicktx' include 'annotation-processor' +include 'hd-wallet' include 'governance' //CIPs