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 07f1c47c..d4a8d17b 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