From 58c445e915e878664e7d52ec2551aeda48b58413 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Thu, 2 Oct 2025 16:15:09 +0400 Subject: [PATCH 1/7] feat(wallet)!: view only account SDK support --- Cargo.lock | 9 +- .../tari_wallet_cli/src/command/account.rs | 6 +- .../tari_wallet_cli/src/command/key.rs | 6 +- .../src/command/transaction.rs | 8 +- .../tari_wallet_cli/src/command/validator.rs | 4 +- applications/tari_walletd/Cargo.toml | 1 + .../tari_walletd/src/handlers/accounts.rs | 66 ++-- .../tari_walletd/src/handlers/confidential.rs | 10 +- .../tari_walletd/src/handlers/helpers.rs | 10 +- .../tari_walletd/src/handlers/keys.rs | 8 +- applications/tari_walletd/src/handlers/mod.rs | 1 - .../tari_walletd/src/handlers/nfts.rs | 32 +- .../tari_walletd/src/handlers/settings.rs | 2 +- .../tari_walletd/src/handlers/transaction.rs | 61 ++-- .../tari_walletd/src/handlers/validator.rs | 25 +- applications/tari_walletd/src/lib.rs | 4 +- applications/tari_walletd/src/main.rs | 44 ++- applications/tari_walletd/src/services/mod.rs | 2 + .../{handlers => services}/wasm_optimizer.rs | 0 .../AssetVault/Components/ClaimFees.tsx | 38 ++- .../src/routes/FlowEditor/FlowEditor.tsx | 2 +- .../web_ui/src/routes/Manifest/Manifest.tsx | 2 +- .../src/routes/Wallet/Components/Accounts.tsx | 6 +- .../src/routes/Wallet/Components/Keys.tsx | 20 +- .../src/services/api/hooks/useAccounts.ts | 8 +- .../web_ui/src/services/api/hooks/useKeys.tsx | 4 +- .../services/api/hooks/useTransactions.tsx | 4 +- bindings/src/helpers/enum.ts | 35 ++ bindings/src/index.ts | 1 + bindings/src/types/Account.ts | 6 +- bindings/src/types/ResourceType.ts | 3 - ...AccountOrKeyIndex.ts => AccountOrKeyId.ts} | 3 +- .../AccountsCreateOrGetRequest.ts | 2 +- .../AccountsCreateRequest.ts | 6 +- .../GetValidatorFeesRequest.ts | 4 +- .../src/types/wallet-daemon-client/KeyId.ts | 3 + .../wallet-daemon-client/KeysListResponse.ts | 6 +- .../TransactionSubmitDryRunRequest.ts | 3 +- .../TransactionSubmitManifestRequest.ts | 3 +- .../TransactionSubmitRequest.ts | 3 +- bindings/src/wallet-daemon-client.ts | 3 +- bindings/test/enumHelpers.test.ts | 48 +++ clients/wallet_daemon_client/Cargo.toml | 2 +- clients/wallet_daemon_client/src/types.rs | 30 +- crates/engine/src/transaction/processor.rs | 4 + crates/engine/tests/shenanigans.rs | 3 +- crates/engine_types/src/bucket.rs | 3 +- crates/engine_types/src/proof.rs | 3 +- crates/engine_types/src/resource.rs | 4 +- crates/engine_types/src/resource_container.rs | 3 +- crates/engine_types/src/vault.rs | 3 +- crates/ootle_address/src/ootle_address.rs | 8 + crates/template_builtin/build.rs | 14 +- crates/template_lib/src/args/types.rs | 3 +- crates/template_lib/src/models/bucket.rs | 3 +- .../template_lib/src/models/encrypted_data.rs | 4 +- crates/template_lib/src/models/proof.rs | 3 +- crates/template_lib/src/models/vault.rs | 3 +- crates/template_lib/src/prelude.rs | 3 +- .../src/resource/builder/confidential.rs | 4 +- .../src/resource/builder/fungible.rs | 4 +- .../src/resource/builder/non_fungible.rs | 3 +- .../src/resource/builder/stealth.rs | 4 +- crates/template_lib/src/resource/mod.rs | 104 +----- .../template_lib_types/src/amount/amount.rs | 64 +++- crates/template_lib_types/src/lib.rs | 2 + .../template_lib_types/src/resource_type.rs | 88 +++++ crates/wallet/crypto/Cargo.toml | 5 +- crates/wallet/crypto/src/encryption.rs | 297 +++++++++++++++++ crates/wallet/crypto/src/hashers.rs | 5 +- crates/wallet/crypto/src/lib.rs | 1 + crates/wallet/sdk/Cargo.toml | 2 +- crates/wallet/sdk/src/apis/accounts.rs | 177 ++++++---- .../sdk/src/apis/confidential_outputs.rs | 42 +-- .../sdk/src/apis/confidential_transfer.rs | 70 ++-- crates/wallet/sdk/src/apis/config.rs | 38 ++- crates/wallet/sdk/src/apis/key_manager.rs | 310 ++++++++++-------- crates/wallet/sdk/src/apis/mod.rs | 1 + .../wallet/sdk/src/apis/password_manager.rs | 144 ++++++++ crates/wallet/sdk/src/apis/resources.rs | 10 +- crates/wallet/sdk/src/apis/stealth_outputs.rs | 104 +++--- .../wallet/sdk/src/apis/stealth_transfer.rs | 25 +- crates/wallet/sdk/src/apis/transaction.rs | 4 +- crates/wallet/sdk/src/cipher_seed.rs | 41 +++ crates/wallet/sdk/src/lib.rs | 1 + crates/wallet/sdk/src/models/account.rs | 31 +- .../sdk/src/models/confidential_output.rs | 5 +- crates/wallet/sdk/src/models/key.rs | 254 ++++++++++++-- .../wallet/sdk/src/models/stealth_output.rs | 6 +- crates/wallet/sdk/src/models/vault.rs | 3 +- crates/wallet/sdk/src/sdk.rs | 204 +++--------- crates/wallet/sdk/src/storage.rs | 41 ++- .../sdk/tests/confidential_output_api.rs | 19 +- crates/wallet/sdk/tests/support/harness.rs | 18 +- crates/wallet/sdk_services/Cargo.toml | 1 - .../sdk_services/src/account_monitor.rs | 138 ++++++-- .../src/account_recovery/service.rs | 30 +- .../sdk_services/src/utxo_scanner/error.rs | 9 +- .../sdk_services/src/utxo_scanner/scanner.rs | 13 +- .../src/utxo_scanner/scanner_round.rs | 25 +- .../src/utxo_scanner/utxo_recovery.rs | 92 +++++- .../sdk_services/src/utxo_scanner/worker.rs | 2 +- .../2023-02-08-122514_initial/up.sql | 95 +++--- .../storage_sqlite/src/models/account.rs | 13 +- .../{output.rs => confidential_output.rs} | 8 +- .../wallet/storage_sqlite/src/models/mod.rs | 4 +- .../storage_sqlite/src/models/resource.rs | 16 +- .../src/models/stealth_output.rs | 11 +- .../storage_sqlite/src/models/transaction.rs | 2 +- .../src/models/utxo_process_queue.rs | 2 +- .../wallet/storage_sqlite/src/models/vault.rs | 3 +- crates/wallet/storage_sqlite/src/reader.rs | 60 +++- crates/wallet/storage_sqlite/src/schema.rs | 23 +- .../storage_sqlite/src/serialization.rs | 4 +- crates/wallet/storage_sqlite/src/writer.rs | 75 ++++- .../wallet/storage_sqlite/tests/accounts.rs | 16 +- integration_tests/src/wallet_daemon_client.rs | 15 +- .../tests/steps/wallet_daemon.rs | 2 +- utilities/tariswap_test_bench/src/accounts.rs | 19 +- utilities/tariswap_test_bench/src/runner.rs | 4 +- utilities/tariswap_test_bench/src/tariswap.rs | 34 +- 121 files changed, 2453 insertions(+), 1002 deletions(-) rename applications/tari_walletd/src/{handlers => services}/wasm_optimizer.rs (100%) create mode 100644 bindings/src/helpers/enum.ts rename bindings/src/types/wallet-daemon-client/{AccountOrKeyIndex.ts => AccountOrKeyId.ts} (58%) create mode 100644 bindings/src/types/wallet-daemon-client/KeyId.ts create mode 100644 bindings/test/enumHelpers.test.ts create mode 100644 crates/template_lib_types/src/resource_type.rs create mode 100644 crates/wallet/crypto/src/encryption.rs create mode 100644 crates/wallet/sdk/src/apis/password_manager.rs create mode 100644 crates/wallet/sdk/src/cipher_seed.rs rename crates/wallet/storage_sqlite/src/models/{output.rs => confidential_output.rs} (91%) diff --git a/Cargo.lock b/Cargo.lock index 8fb20e390b..d81da0778d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2158,9 +2158,9 @@ checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -11516,11 +11516,14 @@ dependencies = [ name = "tari_ootle_wallet_crypto" version = "0.11.2" dependencies = [ + "argon2 0.5.3", "blake2", "chacha20poly1305", + "crc32fast", "digest", "rand 0.8.5", "serde_json", + "subtle", "tari_crypto", "tari_engine_types", "tari_hashing", @@ -11584,7 +11587,6 @@ dependencies = [ "tari_template_builtin", "tari_template_lib", "tari_transaction", - "tari_transaction_components", "thiserror 1.0.69", "tokio", "url", @@ -11627,6 +11629,7 @@ dependencies = [ "config", "either", "futures 0.3.31", + "hex", "humantime-serde", "include_dir", "indexmap 2.11.4", diff --git a/applications/tari_wallet_cli/src/command/account.rs b/applications/tari_wallet_cli/src/command/account.rs index 3453b1b8ef..9d60d76733 100644 --- a/applications/tari_wallet_cli/src/command/account.rs +++ b/applications/tari_wallet_cli/src/command/account.rs @@ -111,7 +111,7 @@ async fn handle_create(args: CreateArgs, client: &mut WalletDaemonClient) -> Res .create_account(AccountsCreateRequest { account_name: args.account_name, is_default: Some(args.is_default), - key_id: args.key_id, + key_index: args.key_id, }) .await?; @@ -168,7 +168,7 @@ async fn handle_create_free_test_coins( .create_or_get_account(AccountsCreateOrGetRequest { account: Some(account), is_default: None, - key_id: args.key_id, + key_index: args.key_id, }) .await?; resp.account @@ -179,7 +179,7 @@ async fn handle_create_free_test_coins( .create_account(AccountsCreateRequest { account_name: None, is_default: None, - key_id: args.key_id, + key_index: args.key_id, }) .await?; resp.account diff --git a/applications/tari_wallet_cli/src/command/key.rs b/applications/tari_wallet_cli/src/command/key.rs index 780cad660a..b20d8a6a76 100644 --- a/applications/tari_wallet_cli/src/command/key.rs +++ b/applications/tari_wallet_cli/src/command/key.rs @@ -21,7 +21,7 @@ // USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. use clap::Subcommand; -use tari_ootle_wallet_sdk::apis::key_manager::KeyBranch; +use tari_ootle_wallet_sdk::{apis::key_manager::KeyBranch, models::KeyId}; use tari_template_lib::prelude::RistrettoPublicKeyBytes; use tari_wallet_daemon_client::WalletDaemonClient; @@ -66,12 +66,12 @@ impl KeysSubcommand { } } -fn print_keys(keys: Vec<(u64, RistrettoPublicKeyBytes, bool)>) { +fn print_keys(keys: Vec<(KeyId, RistrettoPublicKeyBytes, bool)>) { println!("Key pairs:"); println!(); let mut table = Table::new(); - table.set_titles(vec!["Index", "Public Key", "Active"]); + table.set_titles(vec!["KeyId", "Public Key", "Active"]); for (index, key, is_active) in keys { table.add_row(table_row![index, key, if is_active { "✅" } else { "" }]); } diff --git a/applications/tari_wallet_cli/src/command/transaction.rs b/applications/tari_wallet_cli/src/command/transaction.rs index 1ee649050c..e35f74590a 100644 --- a/applications/tari_wallet_cli/src/command/transaction.rs +++ b/applications/tari_wallet_cli/src/command/transaction.rs @@ -272,7 +272,7 @@ pub async fn handle_submit(args: SubmitArgs, client: &mut WalletDaemonClient) -> let resp = client .submit_transaction_dry_run(TransactionSubmitDryRunRequest { transaction, - signing_key_index: None, + signing_key_id: None, detect_inputs: common.detect_inputs.unwrap_or(true), detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -282,7 +282,7 @@ pub async fn handle_submit(args: SubmitArgs, client: &mut WalletDaemonClient) -> } else { let request = TransactionSubmitRequest { transaction, - signing_key_index: None, + signing_key_id: None, detect_inputs: common.detect_inputs.unwrap_or(true), detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -335,7 +335,7 @@ async fn handle_submit_manifest( let resp = client .submit_transaction_dry_run(TransactionSubmitDryRunRequest { transaction, - signing_key_index: Some(fee_account.key_index), + signing_key_id: fee_account.owner_key_id, detect_inputs: common.detect_inputs.unwrap_or(true), detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -345,7 +345,7 @@ async fn handle_submit_manifest( } else { let request = TransactionSubmitRequest { transaction, - signing_key_index: Some(fee_account.key_index), + signing_key_id: fee_account.owner_key_id, detect_inputs: common.detect_inputs.unwrap_or(true), detect_inputs_use_unversioned: true, proof_ids: vec![], diff --git a/applications/tari_wallet_cli/src/command/validator.rs b/applications/tari_wallet_cli/src/command/validator.rs index ae60298488..e652181b8d 100644 --- a/applications/tari_wallet_cli/src/command/validator.rs +++ b/applications/tari_wallet_cli/src/command/validator.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use clap::{Args, Subcommand}; use tari_ootle_common_types::{shard::Shard, ShardGroup}; use tari_wallet_daemon_client::{ - types::{AccountOrKeyIndex, ClaimValidatorFeesRequest, GetValidatorFeesRequest}, + types::{AccountOrKeyId, ClaimValidatorFeesRequest, GetValidatorFeesRequest}, ComponentAddressOrName, WalletDaemonClient, }; @@ -63,7 +63,7 @@ impl ValidatorSubcommand { pub async fn handle_get_fees(args: GetFeesArgs, client: &mut WalletDaemonClient) -> Result<(), anyhow::Error> { let resp = client .get_validator_fees(GetValidatorFeesRequest { - account_or_key: AccountOrKeyIndex::Account(args.account), + account_or_key: AccountOrKeyId::Account(args.account), shard_group: args.shard_group, }) .await?; diff --git a/applications/tari_walletd/Cargo.toml b/applications/tari_walletd/Cargo.toml index e3984235ef..a1b2647890 100644 --- a/applications/tari_walletd/Cargo.toml +++ b/applications/tari_walletd/Cargo.toml @@ -36,6 +36,7 @@ axum-jrpc = { workspace = true, features = ["anyhow_error"] } clap = { workspace = true, features = ["derive", "env"] } config = { workspace = true } either = { workspace = true } +hex = { workspace = true } humantime-serde = { workspace = true } futures = { workspace = true } include_dir = { workspace = true, optional = true } diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index ad32e963d5..458650bed9 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -34,8 +34,7 @@ use tari_ootle_wallet_sdk_services::events::TransactionSubmittedEvent; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::{ constants::{STEALTH_TARI_RESOURCE_ADDRESS, XTR, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; use tari_transaction::args; use tari_wallet_daemon_client::{ @@ -114,8 +113,13 @@ pub async fn handle_create( .map(Ok) .unwrap_or_else(|| accounts_api.any_accounts_exist().map(|b| !b))?; + let owner_address = match req.key_index { + Some(id) => sdk.key_manager_api().derive_account_address(id)?, + None => sdk.key_manager_api().next_account_address()?, + }; + let acc = accounts_api - .create_account(req.account_name.as_deref(), set_as_default, req.key_id) + .create_account(req.account_name.as_deref(), set_as_default, owner_address) .map_err(|e| { if e.is_name_exists_error() { invalid_request(e) @@ -158,8 +162,8 @@ pub async fn handle_create_or_get( Some(ComponentAddressOrName::Name(ref name)) => accounts_api.get_account_by_name(name).optional()?, // If we cannot find an account with this key index, we'll create one None => req - .key_id - .map(|id| get_account_by_key_index(sdk, id).optional()) + .key_index + .map(|index| get_account_by_key_index(sdk, index).optional()) .transpose()? .flatten(), }; @@ -181,8 +185,13 @@ pub async fn handle_create_or_get( .map(Ok) .unwrap_or_else(|| accounts_api.any_accounts_exist().map(|b| !b))?; + let wallet_keys = match req.key_index { + Some(id) => sdk.key_manager_api().derive_account_address(id)?, + None => sdk.key_manager_api().next_account_address()?, + }; + let acc = accounts_api - .create_account(req.account.as_ref().and_then(|a| a.name()), set_as_default, req.key_id) + .create_account(req.account.as_ref().and_then(|a| a.name()), set_as_default, wallet_keys) .map_err(|e| { if e.is_name_exists_error() { invalid_request(e) @@ -237,14 +246,12 @@ pub async fn handle_list( let sdk = context.wallet_sdk(); let accounts = sdk.accounts_api().get_many(req.offset, req.limit)?; let total = sdk.accounts_api().count()?; - let km = sdk.key_manager_api(); let accounts = accounts .into_iter() .map(|a| { - let account_addr = km.derive_account_address(a.key_index)?; Ok(AccountInfo { - account: a, - address: account_addr.address.to_byte_type(), + account: a.account, + address: a.address, }) }) .collect::>()?; @@ -265,7 +272,7 @@ pub async fn handle_get_balances( if req.refresh { context .account_monitor() - .refresh_account(*account.component_address()) + .refresh_account_with_utxos(*account.component_address()) .await?; } let vaults = sdk.accounts_api().get_vaults_by_account(account.component_address())?; @@ -410,6 +417,10 @@ pub async fn handle_claim_burn( let accounts_api = sdk.accounts_api(); let account = get_account(&account, &accounts_api)?; + let account_owner_key_id = account + .owner_key_id() + .ok_or_else(|| invalid_params("account", Some("cannot claim burn to an account without an owner key")))?; + let network = sdk.config_api().get_network()?; let claim_nonce_keypair = sdk .key_manager_api() @@ -466,26 +477,28 @@ pub async fn handle_claim_burn( })?; let (nonce, output_public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); - let account_owner = sdk.key_manager_api().derive_account_key_pair(account.key_index())?; - let view_only = sdk.key_manager_api().derive_view_only_keypair(account.key_index())?; + let account_owner = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; + let account_owner_public_key = account_owner.to_public_key(); + let view_only = sdk.key_manager_api().get_view_only_key(account.view_only_key_id())?; + let view_only_public_key = view_only.to_public_key(); // NOTE: the confidential encryption format and the bullet proofs currently do not support amounts larger than // u64::MAX. Apart from it being insane/basically impossible to have that much XTR in a single UTXO, the L1 emission // will reach this much in many thousands of years. let encrypted_data = sdk.stealth_crypto_api() - .encrypt_value_and_mask(final_amount_u64, &mask.key, &view_only.public_key, &nonce)?; + .encrypt_value_and_mask(final_amount_u64, &mask.key, &view_only_public_key, &nonce)?; let tag = sdk.stealth_crypto_api().derive_stealth_output_tag( network, &nonce, - &view_only.public_key, + &view_only_public_key, &STEALTH_TARI_RESOURCE_ADDRESS, ); // Create stealth address - used during spend time let stealth_output_owner_public_key = sdk.stealth_crypto_api() - .derive_stealth_owner_public_key(network, &account_owner.public_key, &nonce); + .derive_stealth_owner_public_key(network, &account_owner_public_key, &nonce); let output_statement = UnblindedStealthOutputStatement { statement: UnblindedOutputStatement { @@ -557,6 +570,13 @@ pub async fn handle_create_free_test_coins( .optional()? .ok_or_else(|| not_found(format!("Account with name or address '{}' not found", account,)))?; + let account_owner_key_id = account.owner_key_id().ok_or_else(|| { + invalid_params( + "account", + Some("cannot create free test coins for an account without an owner key"), + ) + })?; + info!( target: LOG_TARGET, "💰️ Creating free test coins for account: {} with amount: {} and max fee: {}", @@ -602,7 +622,7 @@ pub async fn handle_create_free_test_coins( ); } - let account_secret_key = sdk.key_manager_api().derive_account_key(account.key_index())?; + let account_owner_key = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; let transaction = context .transaction_builder() @@ -623,7 +643,7 @@ pub async fn handle_create_free_test_coins( .call_method(*account.component_address(), "pay_fee", args![max_fee]) }) .with_inputs(inputs.into_iter().map(|input| input.into_unversioned())) - .build_and_seal(&account_secret_key.key); + .build_and_seal(&account_owner_key.secret); info!( target: LOG_TARGET, @@ -694,6 +714,10 @@ pub async fn handle_transfer( let (account, mut inputs) = get_account_with_inputs(req.account.as_ref(), &sdk)?; + let account_owner_key_id = account + .owner_key_id() + .ok_or_else(|| invalid_params("account", Some("cannot transfer from an account without an owner key")))?; + // get the source account component address let source_account_address = *account.component_address(); @@ -778,7 +802,7 @@ pub async fn handle_transfer( // build the transaction let max_fee = req.max_fee.unwrap_or(DEFAULT_FEE); - let account_secret_key = sdk.key_manager_api().derive_account_key(account.key_index())?; + let account_owner_key = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; let transaction = builder .with_dry_run(req.dry_run) @@ -807,7 +831,7 @@ pub async fn handle_transfer( } }) .with_inputs(inputs.into_iter().map(|req| req.into_unversioned())) - .build_and_seal(&account_secret_key.key); + .build_and_seal(&account_owner_key.secret); // If dry run we can return the result immediately if req.dry_run { @@ -972,7 +996,7 @@ pub async fn handle_associate_stealth_resource( context .account_monitor() - .refresh_account(*account.component_address()) + .refresh_account_with_utxos(*account.component_address()) .await?; Ok(AccountsAssociateStealthResourceResponse {}) diff --git a/applications/tari_walletd/src/handlers/confidential.rs b/applications/tari_walletd/src/handlers/confidential.rs index b1a9bd9616..7b8c250866 100644 --- a/applications/tari_walletd/src/handlers/confidential.rs +++ b/applications/tari_walletd/src/handlers/confidential.rs @@ -56,6 +56,9 @@ pub async fn handle_create_transfer_proof( } let account = get_account_or_default(req.account.as_ref(), &sdk.accounts_api())?; + let account_owner_key_id = account + .owner_key_id() + .ok_or_else(|| invalid_request("Account does not have an owner key"))?; let vault = sdk .accounts_api() .get_vault_by_resource(account.component_address(), &req.resource_address)?; @@ -83,7 +86,7 @@ pub async fn handle_create_transfer_proof( // TODO: Any errors from here need to unlock the outputs, ideally just roll back (refactor required but doable). // TODO: Wrap up key/encrypted data handling in the wallet SDK - let account_secret = sdk.key_manager_api().derive_account_key(account.key_index())?; + let account_key = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; let output_mask = sdk.key_manager_api().next_key(KeyBranch::ConfidentialMask)?; let (_, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); @@ -98,7 +101,7 @@ pub async fn handle_create_transfer_proof( amount_u64, &output_mask.key, &public_nonce, - &account_secret.key, + &account_key.secret, )?; let resource = sdk.substate_api().fetch_resource(req.resource_address).await?; @@ -157,7 +160,8 @@ pub async fn handle_create_transfer_proof( .to_byte_type(), value: change_amount, sender_public_nonce: Some(public_nonce.to_byte_type()), - encryption_secret_key_index: change_mask.key_index, + view_only_key_id: account.view_only_key_id(), + owner_key_id: account.owner_key_id(), encrypted_data: encrypted_data.clone(), public_asset_tag: None, status: OutputStatus::LockedUnconfirmed, diff --git a/applications/tari_walletd/src/handlers/helpers.rs b/applications/tari_walletd/src/handlers/helpers.rs index f77ae4a434..b6928b49e6 100644 --- a/applications/tari_walletd/src/handlers/helpers.rs +++ b/applications/tari_walletd/src/handlers/helpers.rs @@ -10,7 +10,7 @@ use tari_ootle_common_types::{ }; use tari_ootle_wallet_sdk::{ apis::accounts::{AccountsApi, AccountsApiError}, - models::AccountWithAddress, + models::{AccountWithAddress, DerivedKeyIndex}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, @@ -124,16 +124,16 @@ where pub(crate) fn get_account_by_key_index( sdk: &WalletSdk, - key_index: u64, + key_index: DerivedKeyIndex, ) -> Result where TStore: WalletStore, TNetworkInterface: WalletNetworkInterface, TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { - let (_, pk) = sdk.key_manager_api().derive_account_keypair(key_index)?; - let pk = pk.to_byte_type(); - let address = derive_component_address_from_public_key(&ACCOUNT_TEMPLATE_ADDRESS, &pk); + let key = sdk.key_manager_api().derive_account_address(key_index)?; + let address = + derive_component_address_from_public_key(&ACCOUNT_TEMPLATE_ADDRESS, &key.address.account_key().to_byte_type()); sdk.accounts_api().get_account_by_address(&address) } diff --git a/applications/tari_walletd/src/handlers/keys.rs b/applications/tari_walletd/src/handlers/keys.rs index f5ce2671d6..5cfa5807e9 100644 --- a/applications/tari_walletd/src/handlers/keys.rs +++ b/applications/tari_walletd/src/handlers/keys.rs @@ -44,11 +44,11 @@ pub async fn handle_list( ) -> Result { let sdk = context.wallet_sdk(); context.check_auth(token, &[JrpcPermission::KeyList])?; - let keys = sdk.key_manager_api().get_all_keys(req.branch)?; + let keys = sdk.key_manager_api().get_all_derived_keys(req.branch)?; Ok(KeysListResponse { keys: keys .into_iter() - .map(|key| (key.key_index(), key.public_key().to_byte_type(), key.is_active)) + .map(|key| (key.key_id(), key.public_key().to_byte_type(), key.is_active())) .collect(), }) } @@ -62,9 +62,9 @@ pub async fn handle_set_active( context.check_auth(token, &[JrpcPermission::Admin])?; let km = sdk.key_manager_api(); km.set_active_key(KeyBranch::Account, req.index)?; - let (_, key) = km.get_active_key(KeyBranch::Account)?; + let key = km.get_active_key(KeyBranch::Account)?; Ok(KeysSetActiveResponse { - public_key: RistrettoPublicKey::from_secret_key(&key.key).to_byte_type(), + public_key: key.to_public_key().to_byte_type(), }) } diff --git a/applications/tari_walletd/src/handlers/mod.rs b/applications/tari_walletd/src/handlers/mod.rs index 29219f1b6f..00c64b20d3 100644 --- a/applications/tari_walletd/src/handlers/mod.rs +++ b/applications/tari_walletd/src/handlers/mod.rs @@ -17,7 +17,6 @@ pub mod templates; pub mod transaction; pub mod validator; pub mod wallet; -pub mod wasm_optimizer; pub mod webauthn; pub mod webrtc; diff --git a/applications/tari_walletd/src/handlers/nfts.rs b/applications/tari_walletd/src/handlers/nfts.rs index a39e14b8fd..1ac733372c 100644 --- a/applications/tari_walletd/src/handlers/nfts.rs +++ b/applications/tari_walletd/src/handlers/nfts.rs @@ -91,7 +91,11 @@ pub async fn handle_mint_faucet( let account = get_account(&req.account, &sdk.accounts_api())?; let account = account.account; - let signing_key = key_manager_api.derive_account_key(account.key_index)?; + let account_owner_key_id = account + .owner_key_id + .ok_or_else(|| invalid_params("account", Some("The account does not have an owner key ID")))?; + + let signing_key = key_manager_api.get_account_owner_key(account_owner_key_id)?; info!(target: LOG_TARGET, "🎮 Minting new NFT with metadata {}", req.mutable_data); @@ -118,7 +122,7 @@ pub async fn handle_mint_faucet( .with_inputs(inputs.into_iter().map(|input| input.into_unversioned())) .add_input(NFT_FAUCET_COMPONENT_ADDRESS) .add_input(NFT_FAUCET_RESOURCE_ADDRESS) - .build_and_seal(&signing_key.key); + .build_and_seal(&signing_key.secret); let mut events = context.notifier().subscribe(); let tx_id = context.transaction_service().submit_transaction(transaction).await?; @@ -224,8 +228,20 @@ pub async fn handle_transfer( // fetch accounts and its inputs let (fee_payer_account, fee_payer_account_inputs) = get_account_with_inputs(Some(&req.fee_payer_account), sdk)?; let fee_payer_account = fee_payer_account.account; + let fee_payer_key_id = fee_payer_account.owner_key_id.ok_or_else(|| { + invalid_params( + "fee_payer_account", + Some("The fee payer account does not have an owner key ID"), + ) + })?; let fee_payer_account_address = fee_payer_account.component_address; let (source_account, mut inputs) = get_account_with_inputs(Some(&req.source_account), sdk)?; + let account_owner_key_id = source_account.account.owner_key_id().ok_or_else(|| { + invalid_params( + "source_account", + Some("The source account does not have an owner key ID"), + ) + })?; inputs.extend(fee_payer_account_inputs); let source_account_address = *source_account.component_address(); @@ -276,11 +292,9 @@ pub async fn handle_transfer( .call_method(target_account_address, "deposit", args![Workspace(format!("b-{i}"))]); } - let (fee_payer_account_secret_key, fee_payer_account_public_key) = sdk - .key_manager_api() - .derive_account_keypair(fee_payer_account.key_index)?; + let fee_owner_key = sdk.key_manager_api().get_account_owner_key(fee_payer_key_id)?; - let source_account_secret_key = sdk.key_manager_api().derive_account_key(source_account.key_index())?; + let source_account_secret_key = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; let transaction = builder .with_dry_run(req.dry_run) @@ -289,10 +303,10 @@ pub async fn handle_transfer( // Seal signer is the fee payer account .with_authorized_seal_signer() .add_signer( - &fee_payer_account_public_key.to_byte_type(), - &source_account_secret_key.key, + &fee_owner_key.to_public_key().to_byte_type(), + &source_account_secret_key.secret, ) - .build_and_seal(&fee_payer_account_secret_key.key); + .build_and_seal(&fee_owner_key.secret); // if dry run, we can return the result immediately if req.dry_run { diff --git a/applications/tari_walletd/src/handlers/settings.rs b/applications/tari_walletd/src/handlers/settings.rs index 0475d7e06f..af90520352 100644 --- a/applications/tari_walletd/src/handlers/settings.rs +++ b/applications/tari_walletd/src/handlers/settings.rs @@ -42,6 +42,6 @@ pub async fn handle_set( let sdk = context.wallet_sdk(); context.check_auth(token, &[JrpcPermission::Admin])?; sdk.get_network_interface().set_endpoint(&req.indexer_url)?; - sdk.config_api().set(ConfigKey::IndexerUrl, &req.indexer_url, false)?; + sdk.config_api().set(ConfigKey::IndexerUrl, &req.indexer_url)?; Ok(SettingsSetResponse {}) } diff --git a/applications/tari_walletd/src/handlers/transaction.rs b/applications/tari_walletd/src/handlers/transaction.rs index c69d8111dc..7d7c5e33fe 100644 --- a/applications/tari_walletd/src/handlers/transaction.rs +++ b/applications/tari_walletd/src/handlers/transaction.rs @@ -7,7 +7,6 @@ use axum_extra::headers::authorization::Bearer; use axum_jrpc::error::{JsonRpcError, JsonRpcErrorReason}; use futures::{future, future::Either}; use log::*; -use tari_crypto::{keys::PublicKey as _, ristretto::RistrettoPublicKey}; use tari_engine_types::ToByteType; use tari_ootle_common_types::{optional::Optional, Epoch, Network}; use tari_ootle_wallet_sdk::{ @@ -42,10 +41,19 @@ use tari_wallet_daemon_client::{ use tokio::time; use super::context::HandlerContext; -use crate::handlers::{ - helpers::{get_account, get_account_or_default, invalid_params, not_found, transaction_rejected}, - wasm_optimizer::optimize_wasm_template, - HandlerError, +use crate::{ + handlers::{ + helpers::{ + get_account, + get_account_or_default, + invalid_params, + invalid_request, + not_found, + transaction_rejected, + }, + HandlerError, + }, + services::wasm_optimizer::optimize_wasm_template, }; const LOG_TARGET: &str = "tari::ootle::wallet_daemon::handlers::transaction"; @@ -78,7 +86,7 @@ pub async fn handle_submit_instruction( let request = TransactionSubmitRequest { transaction, - signing_key_index: Some(fee_account.key_index()), + signing_key_id: fee_account.owner_key_id(), detect_inputs: req.override_inputs.unwrap_or_default(), detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -97,7 +105,7 @@ pub async fn handle_submit( let key_api = sdk.key_manager_api(); // Fetch the key to sign the transaction // TODO: Ideally the SDK should take care of signing the transaction internally - let (_, key) = key_api.get_key_or_active(KeyBranch::Account, req.signing_key_index)?; + let key = key_api.get_key_or_active(KeyBranch::Account, req.signing_key_id)?; let detected_inputs = if req.detect_inputs { // If we are not overriding inputs, we will use inputs that we know about in the local substate id db @@ -141,7 +149,7 @@ pub async fn handle_submit( .transaction_builder() .with_unsigned_transaction(req.transaction) .with_inputs(detected_inputs) - .build_and_seal(&key.key); + .build_and_seal(&key.secret); if log_enabled!(log::Level::Debug) { for input in transaction.inputs() { @@ -205,7 +213,7 @@ pub async fn handle_submit_dry_run( let key_api = sdk.key_manager_api(); // Fetch the key to sign the transaction // TODO: Ideally the SDK should take care of signing the transaction internally - let (_, key) = key_api.get_key_or_active(KeyBranch::Account, req.signing_key_index)?; + let key = key_api.get_key_or_active(KeyBranch::Account, req.signing_key_id)?; let detected_inputs = if req.detect_inputs { // If we are not overriding inputs, we will use inputs that we know about in the local substate id db @@ -234,7 +242,7 @@ pub async fn handle_submit_dry_run( .with_unsigned_transaction(req.transaction) .with_inputs(detected_inputs) .with_dry_run(true) - .build_and_seal(&key.key); + .build_and_seal(&key.secret); for proof_id in req.proof_ids { // update the proofs table with the corresponding transaction hash @@ -282,19 +290,30 @@ pub async fn handle_submit_manifest( let instructions = parse_manifest(&req.manifest, variables, Default::default()) .map_err(|e| invalid_params("manifest", Some(format!("Failed to parse manifest: {}", e))))?; - let default_account = get_account_or_default(None, &sdk.accounts_api())?; + let default_account = sdk + .accounts_api() + .get_default() + .optional()? + .ok_or_else(|| invalid_request("No default account found".to_string()))?; + + let account_owner_key_id = default_account.owner_key_id().ok_or_else(|| { + invalid_params( + "signing_key_id", + Some("Default account does not have an owner key set".to_string()), + ) + })?; - let signing_key_index = req.signing_key_index.unwrap_or(default_account.key_index()); - let (_, key) = sdk + let signing_key_id = req.signing_key_id.unwrap_or(account_owner_key_id); + let key = sdk .key_manager_api() - .get_key_or_active(KeyBranch::Account, Some(signing_key_index))?; - let seal_signer_pk = RistrettoPublicKey::from_secret_key(&key.key); + .get_key_or_active(KeyBranch::Account, Some(signing_key_id))?; + let seal_signer_pk = key.to_public_key(); let network = context.wallet_sdk().config_api().get::(ConfigKey::Network)?; let fee_amount = req.max_fee; - let acc_key = sdk.key_manager_api().derive_account_key(default_account.key_index())?; + let acc_key = sdk.key_manager_api().get_account_owner_key(account_owner_key_id)?; let builder = Transaction::builder() .for_network(network.as_byte()) .with_fee_instructions_builder(|builder| { @@ -306,10 +325,10 @@ pub async fn handle_submit_manifest( }) .with_instructions(instructions.instructions) .then(|builder| { - if signing_key_index == default_account.key_index() { + if signing_key_id == account_owner_key_id { builder } else { - builder.add_signer(&seal_signer_pk.to_byte_type(), &acc_key.key) + builder.add_signer(&seal_signer_pk.to_byte_type(), &acc_key.secret) } }); let signatures = builder.signatures().to_vec(); @@ -328,7 +347,7 @@ pub async fn handle_submit_manifest( .with_inputs(inputs) .authorized_sealed_signer() .build(signatures) - .seal(&key.key); + .seal(&key.secret); if req.dry_run { let exec_result = context @@ -519,7 +538,7 @@ pub async fn handle_publish_template( if req.dry_run { let request = TransactionSubmitDryRunRequest { transaction, - signing_key_index: Some(fee_account.key_index()), + signing_key_id: fee_account.owner_key_id(), detect_inputs: req.detect_inputs, detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -540,7 +559,7 @@ pub async fn handle_publish_template( } let request = TransactionSubmitRequest { transaction, - signing_key_index: Some(fee_account.key_index()), + signing_key_id: fee_account.owner_key_id(), detect_inputs: req.detect_inputs, detect_inputs_use_unversioned: true, proof_ids: vec![], diff --git a/applications/tari_walletd/src/handlers/validator.rs b/applications/tari_walletd/src/handlers/validator.rs index be25682cc5..3c7957a65a 100644 --- a/applications/tari_walletd/src/handlers/validator.rs +++ b/applications/tari_walletd/src/handlers/validator.rs @@ -7,7 +7,6 @@ use anyhow::anyhow; use axum_extra::headers::authorization::Bearer; use either::Either; use log::*; -use tari_crypto::{keys::PublicKey as _, ristretto::RistrettoPublicKey}; use tari_engine_types::{substate::SubstateId, ToByteType}; use tari_ootle_common_types::{derive_fee_pool_address, SubstateAddress, SubstateRequirement}; use tari_template_lib::constants::XTR; @@ -15,7 +14,7 @@ use tari_transaction::args; use tari_wallet_daemon_client::{ permissions::JrpcPermission, types::{ - AccountOrKeyIndex, + AccountOrKeyId, ClaimValidatorFeesRequest, ClaimValidatorFeesResponse, FeePoolDetails, @@ -44,13 +43,16 @@ pub async fn handle_get_validator_fees( context.check_auth(token, &[JrpcPermission::Admin])?; let claim_key = match req.account_or_key { - AccountOrKeyIndex::Account(acc) => { + AccountOrKeyId::Account(acc) => { let account = get_account_or_default(acc.as_ref(), &sdk.accounts_api())?; - sdk.key_manager_api().derive_account_key(account.key_index())? + let account_key_id = account.owner_key_id().ok_or_else(|| { + anyhow!("The specified account does not have an associated owner key to derive the claim key from") + })?; + sdk.key_manager_api().get_account_owner_key(account_key_id)? }, - AccountOrKeyIndex::KeyIndex(index) => sdk.key_manager_api().derive_account_key(index)?, + AccountOrKeyId::KeyId(key_id) => sdk.key_manager_api().get_account_owner_key(key_id)?, }; - let claim_public_key = RistrettoPublicKey::from_secret_key(&claim_key.key).to_byte_type(); + let claim_public_key = claim_key.to_public_key().to_byte_type(); let shards = req .shard_group @@ -109,15 +111,18 @@ pub async fn handle_claim_validator_fees( } let (account, inputs) = get_account_with_inputs(req.account.as_ref(), &sdk)?; + let account_key_id = account.owner_key_id().ok_or_else(|| { + anyhow!("The specified account does not have an associated owner key to derive the claim key from") + })?; let account_component_address = *account.component_address(); - let (account_secret_key, account_public_key) = sdk.key_manager_api().derive_account_keypair(account.key_index())?; + let account_key = sdk.key_manager_api().get_account_owner_key(account_key_id)?; let (claim_public_key, claim_secret) = match req.claim_key_index { Some(index) => { let (claim_key, claim_pk) = sdk.key_manager_api().derive_account_keypair(index)?; (claim_pk, Some(claim_key)) }, - None => (RistrettoPublicKey::from_secret_key(&account_secret_key.key), None), + None => (account_key.to_public_key(), None), }; let fee_pool_addresses = req @@ -177,12 +182,12 @@ pub async fn handle_claim_validator_fees( // If the claim key is different from the account secret, we need to sign with both builder .with_authorized_seal_signer() - .add_signer(&account_public_key.to_byte_type(), &secret.key) + .add_signer(&account_key.to_public_key().to_byte_type(), &secret.key) } else { builder } }) - .build_and_seal(&account_secret_key.key); + .build_and_seal(&account_key.secret); // send the transaction if req.dry_run { diff --git a/applications/tari_walletd/src/lib.rs b/applications/tari_walletd/src/lib.rs index fd315383cf..656760e1da 100644 --- a/applications/tari_walletd/src/lib.rs +++ b/applications/tari_walletd/src/lib.rs @@ -39,6 +39,7 @@ use tari_ootle_wallet_sdk::{ config::{ConfigApi, ConfigKey}, key_manager::KeyBranch, }, + cipher_seed::CipherSeedRestore, WalletSdk, WalletSdkConfig, }; @@ -74,7 +75,8 @@ pub async fn run_tari_ootle_walletd( let wallet_store = init_wallet_store(&config)?; let mut wallet_sdk = initialize_wallet_sdk(&config, wallet_store.clone())?; - let needs_seed_recovery = wallet_sdk.initialize_cipher_seed(seed_words)?; + let needs_seed_recovery = + wallet_sdk.initialize_cipher_seed(seed_words.map(CipherSeedRestore::FromSeedWords).unwrap_or_default())?; wallet_sdk.key_manager_api().get_or_create_initial(KeyBranch::Account)?; diff --git a/applications/tari_walletd/src/main.rs b/applications/tari_walletd/src/main.rs index d5f0e54bf8..52d0dc2511 100644 --- a/applications/tari_walletd/src/main.rs +++ b/applications/tari_walletd/src/main.rs @@ -26,9 +26,10 @@ use anyhow::Context; use log::*; use serde_json::json; use tari_common::initialize_logging; +use tari_crypto::tari_utilities::ByteArray; use tari_engine_types::ToByteType; use tari_ootle_app_utilities::configuration::load_configuration; -use tari_ootle_wallet_sdk::apis::key_manager::KeyBranch; +use tari_ootle_wallet_sdk::{apis::key_manager::KeyBranch, cipher_seed::CipherSeedRestore}; use tari_ootle_walletd::{ cli::{Cli, Subcommand}, config::ApplicationConfig, @@ -42,7 +43,7 @@ const LOG_TARGET: &str = "tari::wallet_daemon"; #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - // Setup a panic hook which prints the default rust panic message but also exits the process. This makes a panic in + // Set up a panic hook which prints the default rust panic message but also exits the process. This makes a panic in // any thread "crash" the system instead of silently continuing. let default_hook = panic::take_hook(); panic::set_hook(Box::new(move |info| { @@ -72,7 +73,13 @@ async fn main() -> Result<(), anyhow::Error> { }) => { let wallet_store = init_wallet_store(&config)?; let mut sdk = initialize_wallet_sdk(&config, wallet_store)?; - sdk.initialize_cipher_seed(cli.wallet_restore.seed_words.as_ref())?; + sdk.initialize_cipher_seed( + cli.wallet_restore + .seed_words + .as_ref() + .map(CipherSeedRestore::FromSeedWords) + .unwrap_or_default(), + )?; let km = sdk.key_manager_api(); let account_address = if let Some(index) = key_index { km.derive_account_address(*index)? @@ -83,19 +90,30 @@ async fn main() -> Result<(), anyhow::Error> { let public_key = account_address.address.account_key().to_byte_type(); let view_only_public_key = account_address.address.view_only_key().to_byte_type(); let account_addr = sdk.accounts_api().derive_account_address_from_public_key(&public_key); - sdk.accounts_api() - .add_account(name.as_deref(), &account_addr, account_address.key_index, false, true)?; + sdk.accounts_api().add_account( + name.as_deref(), + &account_addr, + account_address.view_only_key_id, + account_address.owner_key_id, + false, + true, + )?; if *set_active { - km.set_active_key(KeyBranch::Account, account_address.key_index)?; + if let Some(index) = account_address.owner_key_id.derived_index() { + km.set_active_key(KeyBranch::Account, index)?; + } } + let view_only_secret = km.get_view_only_key(account_address.view_only_key_id)?; + let json = json!({ "component_address": account_addr, "address": account_address.address.to_byte_type(), - "public_key": public_key, - "view_only_key": view_only_public_key, - "key_index": account_address.key_index, + "account_public_key": public_key, + "view_only_public_key": view_only_public_key, + "view_only_private_key": hex::encode(view_only_secret.secret().as_bytes()), + "key_index": account_address.view_only_key_id, }); match output_path { Some(path) => { @@ -118,7 +136,13 @@ async fn main() -> Result<(), anyhow::Error> { Some(Subcommand::SeedWords) => { let wallet_store = init_wallet_store(&config)?; let mut sdk = initialize_wallet_sdk(&config, wallet_store)?; - sdk.initialize_cipher_seed(cli.wallet_restore.seed_words.as_ref())?; + sdk.initialize_cipher_seed( + cli.wallet_restore + .seed_words + .as_ref() + .map(CipherSeedRestore::FromSeedWords) + .unwrap_or(CipherSeedRestore::CreateNewIfRequired), + )?; let seed_words = sdk.load_seed_words()?; println!("{}", seed_words.join(" ").reveal()) }, diff --git a/applications/tari_walletd/src/services/mod.rs b/applications/tari_walletd/src/services/mod.rs index f881b29ae7..677487f890 100644 --- a/applications/tari_walletd/src/services/mod.rs +++ b/applications/tari_walletd/src/services/mod.rs @@ -1,6 +1,8 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause +pub mod wasm_optimizer; + mod webauthn; pub use webauthn::*; diff --git a/applications/tari_walletd/src/handlers/wasm_optimizer.rs b/applications/tari_walletd/src/services/wasm_optimizer.rs similarity index 100% rename from applications/tari_walletd/src/handlers/wasm_optimizer.rs rename to applications/tari_walletd/src/services/wasm_optimizer.rs diff --git a/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/ClaimFees.tsx b/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/ClaimFees.tsx index 05f7825b34..1e28356be7 100644 --- a/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/ClaimFees.tsx +++ b/applications/tari_walletd/web_ui/src/routes/AssetVault/Components/ClaimFees.tsx @@ -37,6 +37,8 @@ import { AccountInfo, getRejectReasonFromTransactionResult, GetValidatorFeesResponse, + KeyId, + matchesTypeEnum, rejectReasonToString, substateIdToString, TransactionResult, @@ -92,8 +94,8 @@ export default function ClaimFees() { if (keyIndex === formState.keyIndex) { return; } - const selected_account = dataAccountsList?.accounts.find( - (account: AccountInfo) => account.account.key_index === keyIndex, + const selected_account = dataAccountsList?.accounts.find((account: AccountInfo) => + matchesTypeEnum(account.account.owner_key_id, { Derived: { index: BigInt(keyIndex) } }), ); const account = selected_account?.account.component_address ? substateIdToString(selected_account!.account.component_address) @@ -186,7 +188,7 @@ export default function ClaimFees() { setIsLoading(true); try { const fees = await validatorsGetFees({ - account_or_key: { KeyIndex: formState.keyIndex }, + account_or_key: { KeyId: { Derived: { index: BigInt(formState.keyIndex) } } }, shard_group: null, }); setScannedFees(fees); @@ -198,11 +200,31 @@ export default function ClaimFees() { } }; - const formatKey = ([index, publicKey, _isActive]: [number, string, boolean]) => { - let account = dataAccountsList?.accounts.find((account: AccountInfo) => account.account.key_index === +index); + function extractKeyIndex(keyId: KeyId): bigint | null { + if ("Derived" in keyId) { + return keyId.Derived.index; + } + return null; + } + + const formatKey = ([keyId, publicKey, _isActive]: [KeyId, string, boolean]) => { + let account = dataAccountsList?.accounts.find((account: AccountInfo) => + matchesTypeEnum(account.account.owner_key_id, keyId), + ); + + function displayKeyId(keyId: KeyId): string { + if ("Derived" in keyId) { + return `Derived:${keyId.Derived.index}`; + } + if ("Imported" in keyId) { + return `Imported:${keyId.Imported.local_key_id}`; + } + return JSON.stringify(keyId); + } + return (
- {index} {publicKey} + {displayKeyId(keyId)} {publicKey}

Account {account?.account.name || ""}
); @@ -228,8 +250,8 @@ export default function ClaimFees() { style={{ flexGrow: 1, minWidth: "200px" }} disabled={disabled} > - {dataKeysList?.keys.map((account: [number, string, boolean]) => ( - + {dataKeysList?.keys.map((account: [KeyId, string, boolean], i) => ( + {formatKey(account)} ))} diff --git a/applications/tari_walletd/web_ui/src/routes/FlowEditor/FlowEditor.tsx b/applications/tari_walletd/web_ui/src/routes/FlowEditor/FlowEditor.tsx index 326df51946..dbb79ca952 100644 --- a/applications/tari_walletd/web_ui/src/routes/FlowEditor/FlowEditor.tsx +++ b/applications/tari_walletd/web_ui/src/routes/FlowEditor/FlowEditor.tsx @@ -208,7 +208,7 @@ function FlowEditor() { } const request = { transaction: { V1: transaction }, - signing_key_index: account.account.key_index, + signing_key_id: account.account.owner_key_id, detect_inputs: true, detect_inputs_use_unversioned: true, proof_ids: [], diff --git a/applications/tari_walletd/web_ui/src/routes/Manifest/Manifest.tsx b/applications/tari_walletd/web_ui/src/routes/Manifest/Manifest.tsx index c923f44335..9f8884504b 100644 --- a/applications/tari_walletd/web_ui/src/routes/Manifest/Manifest.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Manifest/Manifest.tsx @@ -67,7 +67,7 @@ function ManifestEditor() { manifest: manifest.code, variables: manifest.variables, max_fee: isDryRun ? 3000 : Number(fee), - signing_key_index: null, + signing_key_id: null, dry_run: isDryRun, }) .then((response) => { diff --git a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Accounts.tsx b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Accounts.tsx index bf671a50de..a3b063a3d9 100644 --- a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Accounts.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Accounts.tsx @@ -62,7 +62,11 @@ function Account(account: AccountInfo, index: number) { - {account.account.key_index} + + {account.account.owner_key_id && "Derived" in account.account.owner_key_id + ? account.account.owner_key_id.Derived.index.toString() + : "imported"} + diff --git a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx index de835f1076..9c1d512ca1 100644 --- a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx @@ -35,13 +35,15 @@ import { Form } from "react-router-dom"; import Button from "@mui/material/Button/Button"; import { DataTableCell } from "@components/StyledComponents"; import FetchStatusCheck from "@components/FetchStatusCheck"; +import { KeyId } from "@tari-project/typescript-bindings"; -function Key(key: [number, string, boolean], setActive: any) { +function Key([key, pk, active]: [KeyId, string, boolean], setActive: (key_id: KeyId) => void) { return ( - - {key[0]} - {key[1]} - {key[2] ? Active :
setActive(key[0])}>Activate
}
+ + {/* @ts-ignore */} + {key.Derived.index} + {pk} + {active ? Active :
setActive(key)}>Activate
}
); } @@ -56,8 +58,10 @@ function Keys() { setShowAddKeyDialog(setElseToggle); }; - const setActive = (index: number) => { - mutateSetActive(index); + const setActive = (keyId: KeyId) => { + if ("Derived" in keyId) { + mutateSetActive(keyId.Derived.index); + } }; const onSubmitAddKey = () => { @@ -101,7 +105,7 @@ function Keys() { Active
- {data && data.keys.map((key: [number, string, boolean]) => Key(key, setActive))} + {data && data.keys.map((key: [KeyId, string, boolean]) => Key(key, setActive))} diff --git a/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts b/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts index c4c0b4afc6..9e5ad4dfa2 100644 --- a/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts +++ b/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts @@ -40,7 +40,7 @@ import { import { ApiError } from "@api/helpers/types"; import queryClient from "@api/queryClient"; import { - AccountOrKeyIndex, + AccountOrKeyId, ClaimBurnRequest, ComponentAddress, ComponentAddressOrName, @@ -78,7 +78,7 @@ export const useAccountsCreate = () => { return await accountsCreate({ account_name: req.accountName || "", is_default: req.isDefault || null, - key_id: req.keyId || null, + key_index: req.keyId || null, }); }, onError: (error: ApiError) => { @@ -312,10 +312,10 @@ export const useAccountsGet = (account: ComponentAddress) => { // }); // }; -export const useValidatorFees = (accountOrKeyIndex: AccountOrKeyIndex, shardGroup = null) => { +export const useValidatorFees = (accountOrKeyId: AccountOrKeyId, shardGroup = null) => { return useQuery({ queryKey: ["validator_fees"], - queryFn: () => validatorsGetFees({ account_or_key: accountOrKeyIndex, shard_group: shardGroup }), + queryFn: () => validatorsGetFees({ account_or_key: accountOrKeyId, shard_group: shardGroup }), }); }; diff --git a/applications/tari_walletd/web_ui/src/services/api/hooks/useKeys.tsx b/applications/tari_walletd/web_ui/src/services/api/hooks/useKeys.tsx index 0791e373b5..7fa240d938 100644 --- a/applications/tari_walletd/web_ui/src/services/api/hooks/useKeys.tsx +++ b/applications/tari_walletd/web_ui/src/services/api/hooks/useKeys.tsx @@ -48,8 +48,8 @@ export const useKeysCreate = (branch: KeyBranch) => { }; export const useKeysSetActive = () => { - const setActive = async (index: number) => { - const result = await keysSetActive({ index }); + const setActive = async (index: bigint) => { + const result = await keysSetActive({ index: Number(index) }); return result; }; diff --git a/applications/tari_walletd/web_ui/src/services/api/hooks/useTransactions.tsx b/applications/tari_walletd/web_ui/src/services/api/hooks/useTransactions.tsx index 54728ec568..ae511ea7fb 100644 --- a/applications/tari_walletd/web_ui/src/services/api/hooks/useTransactions.tsx +++ b/applications/tari_walletd/web_ui/src/services/api/hooks/useTransactions.tsx @@ -26,13 +26,11 @@ import { transactionsGetAll, transactionsPublishTemplate, transactionsSubmitManifest, - transactionsWaitResult, - validatorsGetFees, } from "@utils/json_rpc"; import { ApiError } from "@api/helpers/types"; import queryClient from "@api/queryClient"; -import type { AccountOrKeyIndex, TransactionGetAllRequest, TransactionStatus } from "@tari-project/typescript-bindings"; +import type { TransactionGetAllRequest } from "@tari-project/typescript-bindings"; export const useTransactionDetails = (hash: string) => { return useQuery({ diff --git a/bindings/src/helpers/enum.ts b/bindings/src/helpers/enum.ts new file mode 100644 index 0000000000..8226f75b99 --- /dev/null +++ b/bindings/src/helpers/enum.ts @@ -0,0 +1,35 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +export function matchesTypeEnum(enumObject: T | null, value: T | null): boolean { + if (enumObject === null && value === null) { + return true; + } + if (enumObject === null || value === null) { + return false; + } + + const keys = Object.keys(enumObject); + if (keys.length !== 1) { + throw new Error("Enum object must have exactly one key"); + } + const key = keys[0] as keyof T; + if (!(key in value)) { + return false; + } + + // check the value + const enumValue = (enumObject as any)[key]; + const valueValue = (value as any)[key]; + + // Check for primitive types + if (typeof enumValue === "string" || typeof enumValue === "number" || typeof enumValue === "boolean") { + return typeof enumValue === typeof valueValue; + } + + // Check for object types (shallow check) + if (typeof enumValue === "object" && enumValue !== null) { + return enumValue === valueValue; + } + return false; +} diff --git a/bindings/src/index.ts b/bindings/src/index.ts index c764d9a92b..bda8aae6ba 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -175,6 +175,7 @@ export * from "./validator-node-client"; export * from "./wallet-daemon-client"; export * from "./helpers/BigAmount"; export * from "./helpers/consts"; +export * from "./helpers/enum"; export * from "./helpers/helpers"; export * from "./helpers/NetworkByte"; export * from "./helpers/ootleAddress"; diff --git a/bindings/src/types/Account.ts b/bindings/src/types/Account.ts index 1be4d87765..91975afb64 100644 --- a/bindings/src/types/Account.ts +++ b/bindings/src/types/Account.ts @@ -1,10 +1,14 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ComponentAddress } from "./ComponentAddress"; +import type { RistrettoPublicKeyBytes } from "./RistrettoPublicKeyBytes"; +import type { KeyId } from "./wallet-daemon-client/KeyId"; export type Account = { name: string | null; component_address: ComponentAddress; - key_index: number; + view_only_key_id: KeyId; + owner_key_id: KeyId | null; + owner_public_key: RistrettoPublicKeyBytes; is_confirmed_on_chain: boolean; is_default: boolean; }; diff --git a/bindings/src/types/ResourceType.ts b/bindings/src/types/ResourceType.ts index 8674472604..40c47670c2 100644 --- a/bindings/src/types/ResourceType.ts +++ b/bindings/src/types/ResourceType.ts @@ -13,8 +13,5 @@ * - **Stealth** A fungible resource using the highest level of confidentiality. Funds are not kept in vaults, and each * output is an independent confidential substate (kind of like creating a new unlinked vault for each currency * note). - * - * This enum is serializable/deserializable with `serde` and optionally generates - * TypeScript bindings when the `ts` feature is enabled. */ export type ResourceType = "Fungible" | "NonFungible" | "Confidential" | "Stealth"; diff --git a/bindings/src/types/wallet-daemon-client/AccountOrKeyIndex.ts b/bindings/src/types/wallet-daemon-client/AccountOrKeyId.ts similarity index 58% rename from bindings/src/types/wallet-daemon-client/AccountOrKeyIndex.ts rename to bindings/src/types/wallet-daemon-client/AccountOrKeyId.ts index d4cb998821..5607842882 100644 --- a/bindings/src/types/wallet-daemon-client/AccountOrKeyIndex.ts +++ b/bindings/src/types/wallet-daemon-client/AccountOrKeyId.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ComponentAddressOrName } from "./ComponentAddressOrName"; +import type { KeyId } from "./KeyId"; -export type AccountOrKeyIndex = { Account: ComponentAddressOrName | null } | { KeyIndex: number }; +export type AccountOrKeyId = { Account: ComponentAddressOrName | null } | { KeyId: KeyId }; diff --git a/bindings/src/types/wallet-daemon-client/AccountsCreateOrGetRequest.ts b/bindings/src/types/wallet-daemon-client/AccountsCreateOrGetRequest.ts index 0030306dac..065759673a 100644 --- a/bindings/src/types/wallet-daemon-client/AccountsCreateOrGetRequest.ts +++ b/bindings/src/types/wallet-daemon-client/AccountsCreateOrGetRequest.ts @@ -4,5 +4,5 @@ import type { ComponentAddressOrName } from "./ComponentAddressOrName"; export type AccountsCreateOrGetRequest = { account: ComponentAddressOrName | null; is_default: boolean | null; - key_id: number | null; + key_index: number | null; }; diff --git a/bindings/src/types/wallet-daemon-client/AccountsCreateRequest.ts b/bindings/src/types/wallet-daemon-client/AccountsCreateRequest.ts index 7425900ab7..313e56484e 100644 --- a/bindings/src/types/wallet-daemon-client/AccountsCreateRequest.ts +++ b/bindings/src/types/wallet-daemon-client/AccountsCreateRequest.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type AccountsCreateRequest = { account_name: string | null; is_default: boolean | null; key_id: number | null }; +export type AccountsCreateRequest = { + account_name: string | null; + is_default: boolean | null; + key_index: number | null; +}; diff --git a/bindings/src/types/wallet-daemon-client/GetValidatorFeesRequest.ts b/bindings/src/types/wallet-daemon-client/GetValidatorFeesRequest.ts index c9f88f4091..328e3d146e 100644 --- a/bindings/src/types/wallet-daemon-client/GetValidatorFeesRequest.ts +++ b/bindings/src/types/wallet-daemon-client/GetValidatorFeesRequest.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ShardGroup } from "../ShardGroup"; -import type { AccountOrKeyIndex } from "./AccountOrKeyIndex"; +import type { AccountOrKeyId } from "./AccountOrKeyId"; -export type GetValidatorFeesRequest = { account_or_key: AccountOrKeyIndex; shard_group: ShardGroup | null }; +export type GetValidatorFeesRequest = { account_or_key: AccountOrKeyId; shard_group: ShardGroup | null }; diff --git a/bindings/src/types/wallet-daemon-client/KeyId.ts b/bindings/src/types/wallet-daemon-client/KeyId.ts new file mode 100644 index 0000000000..178fa00a3d --- /dev/null +++ b/bindings/src/types/wallet-daemon-client/KeyId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type KeyId = { Derived: { index: bigint } } | { Imported: { local_key_id: bigint } }; diff --git a/bindings/src/types/wallet-daemon-client/KeysListResponse.ts b/bindings/src/types/wallet-daemon-client/KeysListResponse.ts index 091e7deae5..1efdd78eb3 100644 --- a/bindings/src/types/wallet-daemon-client/KeysListResponse.ts +++ b/bindings/src/types/wallet-daemon-client/KeysListResponse.ts @@ -1,8 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RistrettoPublicKeyBytes } from "../RistrettoPublicKeyBytes"; +import type { KeyId } from "./KeyId"; export type KeysListResponse = { /** - * (index, public key, is_active) + * (KeyId, public key, is_active) */ - keys: Array<[number, string, boolean]>; + keys: Array<[KeyId, RistrettoPublicKeyBytes, boolean]>; }; diff --git a/bindings/src/types/wallet-daemon-client/TransactionSubmitDryRunRequest.ts b/bindings/src/types/wallet-daemon-client/TransactionSubmitDryRunRequest.ts index 2c072d9aad..ae7655c4cb 100644 --- a/bindings/src/types/wallet-daemon-client/TransactionSubmitDryRunRequest.ts +++ b/bindings/src/types/wallet-daemon-client/TransactionSubmitDryRunRequest.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UnsignedTransaction } from "../UnsignedTransaction"; +import type { KeyId } from "./KeyId"; export type TransactionSubmitDryRunRequest = { transaction: UnsignedTransaction; - signing_key_index: number | null; + signing_key_id: KeyId | null; detect_inputs: boolean; detect_inputs_use_unversioned: boolean; proof_ids: Array; diff --git a/bindings/src/types/wallet-daemon-client/TransactionSubmitManifestRequest.ts b/bindings/src/types/wallet-daemon-client/TransactionSubmitManifestRequest.ts index 8f6e232c0c..5587783cf9 100644 --- a/bindings/src/types/wallet-daemon-client/TransactionSubmitManifestRequest.ts +++ b/bindings/src/types/wallet-daemon-client/TransactionSubmitManifestRequest.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { KeyId } from "./KeyId"; export type TransactionSubmitManifestRequest = { manifest: string; variables: { [key in string]?: string }; - signing_key_index: number | null; + signing_key_id: KeyId | null; max_fee: number; dry_run: boolean; }; diff --git a/bindings/src/types/wallet-daemon-client/TransactionSubmitRequest.ts b/bindings/src/types/wallet-daemon-client/TransactionSubmitRequest.ts index 429abd810c..02140988c7 100644 --- a/bindings/src/types/wallet-daemon-client/TransactionSubmitRequest.ts +++ b/bindings/src/types/wallet-daemon-client/TransactionSubmitRequest.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UnsignedTransaction } from "../UnsignedTransaction"; +import type { KeyId } from "./KeyId"; export type TransactionSubmitRequest = { transaction: UnsignedTransaction; - signing_key_index: number | null; + signing_key_id: KeyId | null; /** * Attempt to infer inputs and their dependencies from instructions. If false, the provided transaction must * contain the required inputs. diff --git a/bindings/src/wallet-daemon-client.ts b/bindings/src/wallet-daemon-client.ts index ddc1332386..f7b3751ef7 100644 --- a/bindings/src/wallet-daemon-client.ts +++ b/bindings/src/wallet-daemon-client.ts @@ -44,6 +44,7 @@ export * from "./types/wallet-daemon-client/ProofsFinalizeRequest"; export * from "./types/wallet-daemon-client/WebauthnStartRegisterRequest"; export * from "./types/wallet-daemon-client/FeePoolDetails"; export * from "./types/wallet-daemon-client/AccountsTransferRequest"; +export * from "./types/wallet-daemon-client/AccountOrKeyId"; export * from "./types/wallet-daemon-client/SettingsSetResponse"; export * from "./types/wallet-daemon-client/WebauthnFinishAuthRequest"; export * from "./types/wallet-daemon-client/KeysListRequest"; @@ -52,6 +53,7 @@ export * from "./types/wallet-daemon-client/TransactionGetResultResponse"; export * from "./types/wallet-daemon-client/AccountsAssociateStealthResourceRequest"; export * from "./types/wallet-daemon-client/ClaimBurnRequest"; export * from "./types/wallet-daemon-client/KeysListResponse"; +export * from "./types/wallet-daemon-client/KeyId"; export * from "./types/wallet-daemon-client/SubstatesGetRequest"; export * from "./types/wallet-daemon-client/TransactionSubmitRequest"; export * from "./types/wallet-daemon-client/AccountsGetBalancesResponse"; @@ -110,7 +112,6 @@ export * from "./types/wallet-daemon-client/AccountSetDefaultResponse"; export * from "./types/wallet-daemon-client/GetNftRequest"; export * from "./types/wallet-daemon-client/SubstatesListResponse"; export * from "./types/wallet-daemon-client/StealthUtxosDecryptValueRequest"; -export * from "./types/wallet-daemon-client/AccountOrKeyIndex"; export * from "./types/wallet-daemon-client/WebauthnStartAuthResponse"; export * from "./types/wallet-daemon-client/TransferNftRequest"; export * from "./types/wallet-daemon-client/KeysSetActiveResponse"; diff --git a/bindings/test/enumHelpers.test.ts b/bindings/test/enumHelpers.test.ts new file mode 100644 index 0000000000..96121498b2 --- /dev/null +++ b/bindings/test/enumHelpers.test.ts @@ -0,0 +1,48 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +import { describe, expect, it } from "vitest"; +import { matchesTypeEnum } from "../src"; + +describe("matchesEnum", () => { + type CustomEnum = { A: string } | { B: number } | { C: boolean }; + + it("matches a custom enum", () => { + const enumObject: CustomEnum | null = { A: "test" }; + const value = { A: "test" }; + expect(matchesTypeEnum(enumObject, value)).toBe(true); + }); + + it("does not match a custom enum with different key", () => { + const enumObject: CustomEnum | null = { A: "test" }; + const value = { B: 123 } as CustomEnum; + expect(matchesTypeEnum(enumObject, value)).toBe(false); + }); + + it("does not match a custom enum with different value type", () => { + const enumObject: CustomEnum | null = { A: "test" }; + const value = { A: 123 } as unknown as CustomEnum; + expect(matchesTypeEnum(enumObject, value)).toBe(false); + }); + + it("throws an error if enum object has multiple keys", () => { + const enumObject = { A: "test", B: 123 } as unknown as CustomEnum; + const value = { A: "test" }; + expect(() => matchesTypeEnum(enumObject, value)).toThrow("Enum object must have exactly one key"); + }); + + it("returns false if enum object has no keys", () => { + const enumObject = {} as unknown as CustomEnum; + const value = { A: "test" }; + expect(() => matchesTypeEnum(enumObject, value)).toThrow("Enum object must have exactly one key"); + }); + + it("matches enum with primitive number value", () => { + const enumObject = null; + const value = { B: 456 }; + expect(matchesTypeEnum(enumObject, value)).toBe(false); + const enumObject2 = { B: 456 }; + const value2 = null; + expect(matchesTypeEnum(enumObject2, value2)).toBe(false); + }); +}); diff --git a/clients/wallet_daemon_client/Cargo.toml b/clients/wallet_daemon_client/Cargo.toml index 9bc721437e..23f299ac24 100644 --- a/clients/wallet_daemon_client/Cargo.toml +++ b/clients/wallet_daemon_client/Cargo.toml @@ -24,7 +24,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } ts-rs = { workspace = true, optional = true } webauthn-rs-proto = { workspace = true } -zeroize = { workspace = true, features = ["serde"] } +zeroize = { workspace = true, features = ["serde", "simd"] } [features] ts = ["ts-rs"] diff --git a/clients/wallet_daemon_client/src/types.rs b/clients/wallet_daemon_client/src/types.rs index 0b78b60ad3..371a30fc50 100644 --- a/clients/wallet_daemon_client/src/types.rs +++ b/clients/wallet_daemon_client/src/types.rs @@ -43,6 +43,8 @@ use tari_ootle_wallet_sdk::{ models::{ Account, AuthoredTemplateModel, + DerivedKeyIndex, + KeyId, NonFungibleToken, OutputStatus, TransactionStatus, @@ -111,8 +113,7 @@ pub struct CallInstructionRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct TransactionSubmitRequest { pub transaction: UnsignedTransaction, - #[cfg_attr(feature = "ts", ts(type = "number | null"))] - pub signing_key_index: Option, + pub signing_key_id: Option, /// Attempt to infer inputs and their dependencies from instructions. If false, the provided transaction must /// contain the required inputs. pub detect_inputs: bool, @@ -139,8 +140,7 @@ pub struct TransactionSubmitResponse { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct TransactionSubmitDryRunRequest { pub transaction: UnsignedTransaction, - #[cfg_attr(feature = "ts", ts(type = "number | null"))] - pub signing_key_index: Option, + pub signing_key_id: Option, pub detect_inputs: bool, pub detect_inputs_use_unversioned: bool, #[cfg_attr(feature = "ts", ts(type = "Array"))] @@ -159,8 +159,7 @@ pub struct TransactionSubmitDryRunResponse { pub struct TransactionSubmitManifestRequest { pub manifest: String, pub variables: HashMap, - #[cfg_attr(feature = "ts", ts(type = "number | null"))] - pub signing_key_index: Option, + pub signing_key_id: Option, #[cfg_attr(feature = "ts", ts(type = "number"))] pub max_fee: u64, pub dry_run: bool, @@ -278,9 +277,8 @@ pub struct KeysListRequest { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct KeysListResponse { - /// (index, public key, is_active) - #[cfg_attr(feature = "ts", ts(type = "Array<[number, string, boolean]>"))] - pub keys: Vec<(u64, RistrettoPublicKeyBytes, bool)>, + /// (KeyId, public key, is_active) + pub keys: Vec<(KeyId, RistrettoPublicKeyBytes, bool)>, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -318,7 +316,7 @@ pub struct AccountsCreateRequest { pub account_name: Option, pub is_default: Option, #[cfg_attr(feature = "ts", ts(type = "number | null"))] - pub key_id: Option, + pub key_index: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -334,7 +332,7 @@ pub struct AccountsCreateOrGetRequest { pub account: Option, pub is_default: Option, #[cfg_attr(feature = "ts", ts(type = "number | null"))] - pub key_id: Option, + pub key_index: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -598,7 +596,7 @@ pub struct ClaimBurnRequest { pub struct ClaimBurnProof { pub claim_proof: MinotariBurnClaimProof, #[cfg_attr(feature = "ts", ts(type = "number"))] - pub owner_nonce_key_index: u64, + pub owner_nonce_key_index: DerivedKeyIndex, pub encrypted_data: EncryptedData, } @@ -767,17 +765,17 @@ pub struct AuthGetAllJwtResponse { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct GetValidatorFeesRequest { - pub account_or_key: AccountOrKeyIndex, + pub account_or_key: AccountOrKeyId, pub shard_group: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] -pub enum AccountOrKeyIndex { +pub enum AccountOrKeyId { /// Query by account. None signifies the default account. Account(Option), - /// Query by key index. - KeyIndex(#[cfg_attr(feature = "ts", ts(type = "number"))] u64), + /// Query by key id. + KeyId(KeyId), } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/engine/src/transaction/processor.rs b/crates/engine/src/transaction/processor.rs index 1504021928..350d0774f7 100644 --- a/crates/engine/src/transaction/processor.rs +++ b/crates/engine/src/transaction/processor.rs @@ -41,6 +41,7 @@ use tari_template_lib::{ auth::{ComponentAccessRules, OwnerRule}, invoke_args, models::{Bucket, NonFungibleAddress, StealthTransferStatement}, + prelude::STEALTH_TARI_RESOURCE_ADDRESS, types::{crypto::RistrettoPublicKeyBytes, TemplateAddress}, }; use tari_transaction::{ @@ -125,6 +126,9 @@ impl + 'static> T let initial_auth_scope = AuthorizationScope::new(auth_params.initial_ownership_proofs); let mut initial_call_scope = CallScope::new(); initial_call_scope.set_auth_scope(initial_auth_scope); + // Because XTR resource is immutable, we can make it available to every shard group (genesis state) and + // transaction (payment of fees) + initial_call_scope.add_substate_to_owned(STEALTH_TARI_RESOURCE_ADDRESS.into()); for input in executable.all_inputs_iter() { debug!( target: LOG_TARGET, diff --git a/crates/engine/tests/shenanigans.rs b/crates/engine/tests/shenanigans.rs index 3d2ada9868..4e7dc44e62 100644 --- a/crates/engine/tests/shenanigans.rs +++ b/crates/engine/tests/shenanigans.rs @@ -7,8 +7,7 @@ use tari_template_lib::{ args::VaultAction, constants::XTR, models::{ComponentAddress, ResourceAddress}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; use tari_template_test_tooling::{support::assert_error::assert_reject_reason, TemplateTest}; use tari_transaction::{args, Transaction}; diff --git a/crates/engine_types/src/bucket.rs b/crates/engine_types/src/bucket.rs index 6890e11b96..43b47af65e 100644 --- a/crates/engine_types/src/bucket.rs +++ b/crates/engine_types/src/bucket.rs @@ -26,8 +26,7 @@ use serde::{Deserialize, Serialize}; use tari_crypto::ristretto::RistrettoPublicKey; use tari_template_lib::{ models::{BucketId, ConfidentialWithdrawProof, NonFungibleId, ResourceAddress}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; use crate::{ diff --git a/crates/engine_types/src/proof.rs b/crates/engine_types/src/proof.rs index cd86d60dcc..6f912b237e 100644 --- a/crates/engine_types/src/proof.rs +++ b/crates/engine_types/src/proof.rs @@ -24,8 +24,7 @@ use std::collections::BTreeSet; use tari_template_lib::{ models::{BucketId, NonFungibleId, ResourceAddress, VaultId}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; use crate::resource_container::ResourceContainer; diff --git a/crates/engine_types/src/resource.rs b/crates/engine_types/src/resource.rs index 06aeb4f0f2..60da6f0d5a 100644 --- a/crates/engine_types/src/resource.rs +++ b/crates/engine_types/src/resource.rs @@ -27,8 +27,8 @@ use tari_crypto::{ristretto::RistrettoPublicKey, tari_utilities::ByteArrayError} use tari_template_lib::{ auth::{AuthHook, OwnerRule, Ownership, ResourceAccessRules}, models::Metadata, - resource::{ResourceType, TOKEN_SYMBOL}, - types::{crypto::RistrettoPublicKeyBytes, Amount}, + resource::TOKEN_SYMBOL, + types::{crypto::RistrettoPublicKeyBytes, Amount, ResourceType}, }; use crate::ConvertFromByteType; diff --git a/crates/engine_types/src/resource_container.rs b/crates/engine_types/src/resource_container.rs index 73ec30597e..79a177cd76 100644 --- a/crates/engine_types/src/resource_container.rs +++ b/crates/engine_types/src/resource_container.rs @@ -18,8 +18,7 @@ use tari_template_lib::{ ResourceAddress, UtxoId, }, - prelude::ResourceType, - types::{crypto::PedersenCommitmentBytes, Amount}, + types::{crypto::PedersenCommitmentBytes, Amount, ResourceType}, }; use crate::{confidential, crypto::PrivateOutput, substate::SubstateId, ToByteType}; diff --git a/crates/engine_types/src/vault.rs b/crates/engine_types/src/vault.rs index e95027bb7e..45f739ed7f 100644 --- a/crates/engine_types/src/vault.rs +++ b/crates/engine_types/src/vault.rs @@ -27,8 +27,7 @@ use tari_crypto::ristretto::RistrettoPublicKey; use tari_template_lib::{ args::VaultFreezeFlags, models::{ConfidentialWithdrawProof, NonFungibleId, ResourceAddress, VaultId}, - prelude::ResourceType, - types::{crypto::PedersenCommitmentBytes, Amount}, + types::{crypto::PedersenCommitmentBytes, Amount, ResourceType}, }; use crate::{ diff --git a/crates/ootle_address/src/ootle_address.rs b/crates/ootle_address/src/ootle_address.rs index b1d9dc68ed..b8757656f9 100644 --- a/crates/ootle_address/src/ootle_address.rs +++ b/crates/ootle_address/src/ootle_address.rs @@ -213,6 +213,14 @@ pub struct RistrettoOotleAddress { } impl RistrettoOotleAddress { + pub fn new(network: Network, view_only_key: RistrettoPublicKey, account_key: RistrettoPublicKey) -> Self { + Self { + network, + view_only_key, + account_key, + } + } + pub fn network(&self) -> Network { self.network } diff --git a/crates/template_builtin/build.rs b/crates/template_builtin/build.rs index 479c938b1b..099dc473f7 100644 --- a/crates/template_builtin/build.rs +++ b/crates/template_builtin/build.rs @@ -13,10 +13,16 @@ use std::{ const TEMPLATE_BUILTINS: &[&str] = &["templates/account", "templates/nft_faucet", "templates/faucet"]; fn main() -> Result<(), Box> { - // Rebuild templates if abi or lib changes - println!("cargo:rerun-if-changed=../template_abi"); - println!("cargo:rerun-if-changed=../template_lib"); - println!("cargo:rerun-if-changed=../tari_bor"); + // Rebuild templates if abi or lib changes (only if they exist in the build context) + if Path::new("../template_abi").exists() { + println!("cargo:rerun-if-changed=../template_abi"); + } + if Path::new("../template_lib").exists() { + println!("cargo:rerun-if-changed=../template_lib"); + } + if Path::new("../tari_bor").exists() { + println!("cargo:rerun-if-changed=../tari_bor"); + } for template in TEMPLATE_BUILTINS { // we only want to rebuild if a template was added/modified println!("cargo:rerun-if-changed={}/src", template); diff --git a/crates/template_lib/src/args/types.rs b/crates/template_lib/src/args/types.rs index 2a1db451b1..23db2d1ff4 100644 --- a/crates/template_lib/src/args/types.rs +++ b/crates/template_lib/src/args/types.rs @@ -26,7 +26,7 @@ use tari_template_abi::rust::{ fmt::{Display, Formatter}, str::FromStr, }; -use tari_template_lib_types::{bytes::Bytes, crypto::StealthValueProof}; +use tari_template_lib_types::{bytes::Bytes, crypto::StealthValueProof, ResourceType}; use crate::{ args::freeze_flags::VaultFreezeFlags, @@ -49,7 +49,6 @@ use crate::{ VaultRef, }, prelude::{ComponentAccessRules, ConfidentialOutputStatement, TemplateAddress}, - resource::ResourceType, template::BuiltinTemplate, types::{ crypto::{PedersenCommitmentBytes, RistrettoPublicKeyBytes}, diff --git a/crates/template_lib/src/models/bucket.rs b/crates/template_lib/src/models/bucket.rs index 32b65e74e2..b8043a67f8 100644 --- a/crates/template_lib/src/models/bucket.rs +++ b/crates/template_lib/src/models/bucket.rs @@ -35,9 +35,8 @@ use super::{ }; use crate::{ args::{BucketAction, BucketInvokeArg, BucketRef, InvokeResult}, - prelude::ResourceType, resource::ResourceManager, - types::Amount, + types::{Amount, ResourceType}, }; const TAG: u64 = BinaryTag::BucketId.as_u64(); diff --git a/crates/template_lib/src/models/encrypted_data.rs b/crates/template_lib/src/models/encrypted_data.rs index de7e0366d7..b791cc121e 100644 --- a/crates/template_lib/src/models/encrypted_data.rs +++ b/crates/template_lib/src/models/encrypted_data.rs @@ -12,7 +12,7 @@ use tari_template_lib_types::serde_helpers; pub struct EncryptedData( #[serde(with = "serde_helpers::dynamic_hex")] #[cfg_attr(feature = "ts", ts(type = "string"))] - Vec, + Box<[u8]>, ); impl EncryptedData { @@ -75,6 +75,6 @@ impl TryFrom> for EncryptedData { if value.len() > Self::max_size() { return Err(value.len()); } - Ok(Self(value)) + Ok(Self(value.into_boxed_slice())) } } diff --git a/crates/template_lib/src/models/proof.rs b/crates/template_lib/src/models/proof.rs index a675e36a6f..cc302e89c8 100644 --- a/crates/template_lib/src/models/proof.rs +++ b/crates/template_lib/src/models/proof.rs @@ -29,8 +29,7 @@ use tari_template_abi::{call_engine, rust::fmt, EngineOp}; use crate::{ args::{InvokeResult, ProofAction, ProofInvokeArg, ProofRef}, models::{BinaryTag, NonFungibleId, ResourceAddress}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; const TAG: u64 = BinaryTag::ProofId.as_u64(); diff --git a/crates/template_lib/src/models/vault.rs b/crates/template_lib/src/models/vault.rs index 7da2fd0d7d..759ae08462 100644 --- a/crates/template_lib/src/models/vault.rs +++ b/crates/template_lib/src/models/vault.rs @@ -57,9 +57,8 @@ use crate::{ VaultWithdrawArg, }, newtype_struct_serde_impl, - prelude::ResourceType, resource::ResourceManager, - types::Amount, + types::{Amount, ResourceType}, }; const TAG: u64 = BinaryTag::VaultId as u64; diff --git a/crates/template_lib/src/prelude.rs b/crates/template_lib/src/prelude.rs index e9a4643a29..c1e917460c 100644 --- a/crates/template_lib/src/prelude.rs +++ b/crates/template_lib/src/prelude.rs @@ -38,6 +38,7 @@ pub use tari_template_lib_types::{ SignaturePayload, }, custom_signature_domain, + ResourceType, TemplateAddress, }; #[cfg(all(feature = "macro", target_arch = "wasm32"))] @@ -86,7 +87,7 @@ pub use crate::{ Verifiable, }, rand, - resource::{ResourceBuilder, ResourceManager, ResourceType}, + resource::{ResourceBuilder, ResourceManager}, rule, template::{BuiltinTemplate, TemplateManager}, types, diff --git a/crates/template_lib/src/resource/builder/confidential.rs b/crates/template_lib/src/resource/builder/confidential.rs index 92638c9200..e43b646370 100644 --- a/crates/template_lib/src/resource/builder/confidential.rs +++ b/crates/template_lib/src/resource/builder/confidential.rs @@ -7,8 +7,8 @@ use crate::{ auth::{AccessRule, AuthHook, OwnerRule, ResourceAccessRules}, models::{Bucket, ComponentAddress, Metadata, ResourceAddress, ResourceAddressAllocation}, prelude::ConfidentialOutputStatement, - resource::{ResourceManager, ResourceType, DEFAULT_DIVISIBILITY}, - types::crypto::RistrettoPublicKeyBytes, + resource::{ResourceManager, DEFAULT_DIVISIBILITY}, + types::{crypto::RistrettoPublicKeyBytes, ResourceType}, }; /// Implements the builder pattern for Confidential resources. diff --git a/crates/template_lib/src/resource/builder/fungible.rs b/crates/template_lib/src/resource/builder/fungible.rs index 280678b866..c7cd628ed1 100644 --- a/crates/template_lib/src/resource/builder/fungible.rs +++ b/crates/template_lib/src/resource/builder/fungible.rs @@ -1,12 +1,14 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause +use tari_template_lib_types::ResourceType; + use super::{IMAGE_URL, TOKEN_SYMBOL}; use crate::{ args::MintArg, auth::{AccessRule, AuthHook, OwnerRule, ResourceAccessRules}, models::{Bucket, ComponentAddress, Metadata, ResourceAddress, ResourceAddressAllocation}, - resource::{ResourceManager, ResourceType, DEFAULT_DIVISIBILITY}, + resource::{ResourceManager, DEFAULT_DIVISIBILITY}, types::Amount, }; /// A builder for creating fungible resources (tokens) inside templates. diff --git a/crates/template_lib/src/resource/builder/non_fungible.rs b/crates/template_lib/src/resource/builder/non_fungible.rs index f86ab818e2..035e634e35 100644 --- a/crates/template_lib/src/resource/builder/non_fungible.rs +++ b/crates/template_lib/src/resource/builder/non_fungible.rs @@ -3,13 +3,14 @@ use serde::Serialize; use tari_bor::to_value; +use tari_template_lib_types::ResourceType; use super::{IMAGE_URL, TOKEN_SYMBOL}; use crate::{ args::MintArg, auth::{AccessRule, AuthHook, OwnerRule, ResourceAccessRules}, models::{Bucket, ComponentAddress, Metadata, NonFungibleId, ResourceAddress, ResourceAddressAllocation}, - resource::{ResourceManager, ResourceType}, + resource::ResourceManager, }; /// Utility for building non-fungible resources inside templates diff --git a/crates/template_lib/src/resource/builder/stealth.rs b/crates/template_lib/src/resource/builder/stealth.rs index b1b03a9c42..ae21f9a380 100644 --- a/crates/template_lib/src/resource/builder/stealth.rs +++ b/crates/template_lib/src/resource/builder/stealth.rs @@ -1,14 +1,14 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use tari_template_lib_types::Amount; +use tari_template_lib_types::{Amount, ResourceType}; use super::{IMAGE_URL, TOKEN_SYMBOL}; use crate::{ args::MintArg, auth::{AccessRule, AuthHook, OwnerRule, ResourceAccessRules}, models::{Bucket, ComponentAddress, Metadata, ResourceAddress, ResourceAddressAllocation}, - resource::{ResourceManager, ResourceType, DEFAULT_DIVISIBILITY}, + resource::{ResourceManager, DEFAULT_DIVISIBILITY}, types::crypto::RistrettoPublicKeyBytes, }; diff --git a/crates/template_lib/src/resource/mod.rs b/crates/template_lib/src/resource/mod.rs index c24cac35f8..771fb483c2 100644 --- a/crates/template_lib/src/resource/mod.rs +++ b/crates/template_lib/src/resource/mod.rs @@ -23,111 +23,11 @@ //! Utilities for building and managing resources inside templates. //! //! This module provides abstractions to define and work with various resource types in the Tari network. -//! Resources can be fungible tokens, non-fungible tokens, or confidential fungible tokens with privacy features. -//! -//! The `ResourceType` enum categorizes these resource types and offers convenience methods for checking the type. -//! -//! # Example -//! ```rust -//! use tari_template_lib::resource::ResourceType; -//! -//! let resource = ResourceType::Fungible; -//! assert!(resource.is_fungible()); -//! ``` -//! -//! The module also re-exports builders and managers for resource creation and lifecycle management. - -use tari_template_abi::rust::{fmt, str::FromStr}; +//! Resources can be fungible tokens, non-fungible tokens, confidential fungible (balance is private), or stealth tokens +//! (private). mod builder; mod manager; pub use builder::*; pub use manager::*; - -/// Represents every possible type of resource in the Tari network. -/// -/// Resources represent digital assets managed within the Tari system, including -/// fungible tokens, non-fungible tokens (NFTs), and confidential fungible tokens. -/// -/// - **Fungible** tokens are interchangeable and divisible (e.g., currency, shares). -/// - **NonFungible** tokens represent unique, indivisible assets (e.g., collectibles). -/// - **Confidential** A type of fungible resource that uses cryptographic privacy to keep balances confidential. Funds -/// are placed in vaults and can therefore be associated with a component that contains them, typically an Account. -/// - **Stealth** A fungible resource using the highest level of confidentiality. Funds are not kept in vaults, and each -/// output is an independent confidential substate (kind of like creating a new unlinked vault for each currency -/// note). -/// -/// This enum is serializable/deserializable with `serde` and optionally generates -/// TypeScript bindings when the `ts` feature is enabled. - -#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] -#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize))] -pub enum ResourceType { - /// Fungible tokens do not have individual identity, making them interchangeable. - /// Examples include monetary units, liquidity pool tokens, or tokenized shares. - Fungible, - /// A resource (i.e., collection) of non-fungible tokens. - /// Each NFT is uniquely identifiable within the parent resource and indivisible. - NonFungible, - /// A type of fungible resource that uses cryptographic privacy to keep balances confidential. Funds are placed in - /// vaults and can therefore be associated with a component that contains them, typically an Account. - Confidential, - /// A fungible resource using the highest level of confidentiality. Funds are not kept in vaults, and each output - /// is an independent confidential substate (kind of like creating a new unlinked vault for each currency note). - Stealth, -} - -impl ResourceType { - /// Returns `true` if the resource type is fungible, otherwise `false`. - pub fn is_fungible(&self) -> bool { - matches!(self, Self::Fungible) - } - - /// Returns `true` if the resource type is non-fungible, otherwise `false`. - pub fn is_non_fungible(&self) -> bool { - matches!(self, Self::NonFungible) - } - - /// Returns `true` if the resource type is confidential fungible, otherwise `false`. - pub fn is_confidential(&self) -> bool { - matches!(self, Self::Confidential) - } - - /// Returns `true` if the resource type is stealth, otherwise `false`. - pub fn is_stealth(&self) -> bool { - matches!(self, Self::Stealth) - } -} - -impl fmt::Display for ResourceType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Debug::fmt(self, f) - } -} - -impl FromStr for ResourceType { - type Err = ParseResourceTypeError; - - fn from_str(s: &str) -> Result { - match s { - "Fungible" => Ok(ResourceType::Fungible), - "NonFungible" | "nft" => Ok(ResourceType::NonFungible), - "Confidential" => Ok(ResourceType::Confidential), - "Stealth" => Ok(ResourceType::Stealth), - _ => Err(ParseResourceTypeError(s.to_string())), - } - } -} - -#[derive(Debug)] -pub struct ParseResourceTypeError(String); - -impl fmt::Display for ParseResourceTypeError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Invalid Resource Type string: '{}'", self.0) - } -} - -impl std::error::Error for ParseResourceTypeError {} diff --git a/crates/template_lib_types/src/amount/amount.rs b/crates/template_lib_types/src/amount/amount.rs index 751db8d0b3..8a0926a167 100644 --- a/crates/template_lib_types/src/amount/amount.rs +++ b/crates/template_lib_types/src/amount/amount.rs @@ -3,7 +3,7 @@ use bnum::BUint; use newtype_ops::newtype_ops; -use tari_template_abi::rust::{cmp, fmt, iter::Sum, ops::Neg, str::FromStr}; +use tari_template_abi::rust::{cmp, fmt, fmt::Debug, iter::Sum, ops::Neg, str::FromStr, write}; use crate::{impl_from, partial_eq_impl, partial_ord_impl}; @@ -313,11 +313,46 @@ impl Amount { None => panic!("Failed to parse Amount from string"), } } + + pub fn to_decimal_string(&self, decimals: u32) -> String { + let mut s = String::new(); + self.fmt_decimals(&mut s, decimals) + .expect("fmt with String is infallible"); + s + } + + pub fn fmt_decimals(&self, f: &mut F, decimals: u32) -> fmt::Result { + if decimals == 0 { + write!(f, "{}", self.inner_value())?; + return Ok(()); + } + + let ten = I192::from(10); + let divisor = ten.pow(decimals); + let integer_part = self.inner_value().div(divisor); + let fractional_part = self.inner_value().rem(divisor).abs(); + + if self.is_negative() && integer_part.is_zero() && !fractional_part.is_zero() { + write!(f, "-")?; + } + + // Format fractional part with leading zeros + write!(f, "{}.", integer_part)?; + + // TODO: calculate the decimal string without allocating a string first + let fractional_str = fractional_part.to_string(); + let mut padding_needed = decimals as usize - fractional_str.len(); + while padding_needed > 0 { + write!(f, "0")?; + padding_needed -= 1; + } + write!(f, "{}", fractional_part) + } } impl fmt::Display for Amount { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.inner_value()) + fmt::Display::fmt(self.inner_value(), f) } } @@ -579,4 +614,29 @@ mod tests { const N2: Amount = Amount::from_str_radix("-12345678901234567890", 10); assert_eq!(N2, Amount::from(-12345678901234567890i128)); } + + #[test] + fn fmt_decimals() { + let a = Amount::from(123456); + assert_eq!(a.to_decimal_string(0), "123456"); + assert_eq!(a.to_decimal_string(2), "1234.56"); + assert_eq!(a.to_decimal_string(5), "1.23456"); + assert_eq!(a.to_decimal_string(6), "0.123456"); + assert_eq!(a.to_decimal_string(8), "0.00123456"); + + let b = Amount::from(-123456); + assert_eq!(b.to_decimal_string(0), "-123456"); + assert_eq!(b.to_decimal_string(2), "-1234.56"); + assert_eq!(b.to_decimal_string(5), "-1.23456"); + assert_eq!(b.to_decimal_string(6), "-0.123456"); + assert_eq!(b.to_decimal_string(8), "-0.00123456"); + + let c = Amount::from(1000); + assert_eq!(c.to_decimal_string(3), "1.000"); + assert_eq!(c.to_decimal_string(5), "0.01000"); + + let c = Amount::from(-1000); + assert_eq!(c.to_decimal_string(3), "-1.000"); + assert_eq!(c.to_decimal_string(8), "-0.00001000"); + } } diff --git a/crates/template_lib_types/src/lib.rs b/crates/template_lib_types/src/lib.rs index 780b2b6b11..b09e65a628 100644 --- a/crates/template_lib_types/src/lib.rs +++ b/crates/template_lib_types/src/lib.rs @@ -11,12 +11,14 @@ mod entity_id; mod error; mod hash; pub mod hex; +mod resource_type; pub mod serde_helpers; pub use amount::*; pub use entity_id::*; pub use error::*; pub use hash::*; +pub use resource_type::*; /// The address of a Template pub type TemplateAddress = Hash; diff --git a/crates/template_lib_types/src/resource_type.rs b/crates/template_lib_types/src/resource_type.rs new file mode 100644 index 0000000000..5c088ef218 --- /dev/null +++ b/crates/template_lib_types/src/resource_type.rs @@ -0,0 +1,88 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_template_abi::rust::{fmt, str::FromStr}; + +/// Represents every possible type of resource in the Tari network. +/// +/// Resources represent digital assets managed within the Tari system, including +/// fungible tokens, non-fungible tokens (NFTs), and confidential fungible tokens. +/// +/// - **Fungible** tokens are interchangeable and divisible (e.g., currency, shares). +/// - **NonFungible** tokens represent unique, indivisible assets (e.g., collectibles). +/// - **Confidential** A type of fungible resource that uses cryptographic privacy to keep balances confidential. Funds +/// are placed in vaults and can therefore be associated with a component that contains them, typically an Account. +/// - **Stealth** A fungible resource using the highest level of confidentiality. Funds are not kept in vaults, and each +/// output is an independent confidential substate (kind of like creating a new unlinked vault for each currency +/// note). +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[cfg_attr(feature = "borsh", derive(borsh::BorshSerialize))] +pub enum ResourceType { + /// Fungible tokens do not have individual identity, making them interchangeable. + /// Examples include monetary units, liquidity pool tokens, or tokenized shares. + Fungible, + /// A resource (i.e., collection) of non-fungible tokens. + /// Each NFT is uniquely identifiable within the parent resource and indivisible. + NonFungible, + /// A type of fungible resource that uses cryptographic privacy to keep balances confidential. Funds are placed in + /// vaults and can therefore be associated with a component that contains them, typically an Account. + Confidential, + /// A fungible resource using the highest level of confidentiality. Funds are not kept in vaults, and each output + /// is an independent confidential substate (kind of like creating a new unlinked vault for each currency note). + Stealth, +} + +impl ResourceType { + /// Returns `true` if the resource type is fungible, otherwise `false`. + pub fn is_fungible(&self) -> bool { + matches!(self, Self::Fungible) + } + + /// Returns `true` if the resource type is non-fungible, otherwise `false`. + pub fn is_non_fungible(&self) -> bool { + matches!(self, Self::NonFungible) + } + + /// Returns `true` if the resource type is confidential fungible, otherwise `false`. + pub fn is_confidential(&self) -> bool { + matches!(self, Self::Confidential) + } + + /// Returns `true` if the resource type is stealth, otherwise `false`. + pub fn is_stealth(&self) -> bool { + matches!(self, Self::Stealth) + } +} + +impl fmt::Display for ResourceType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self, f) + } +} + +impl FromStr for ResourceType { + type Err = ParseResourceTypeError; + + fn from_str(s: &str) -> Result { + match s { + "Fungible" => Ok(ResourceType::Fungible), + "NonFungible" | "nft" => Ok(ResourceType::NonFungible), + "Confidential" => Ok(ResourceType::Confidential), + "Stealth" => Ok(ResourceType::Stealth), + _ => Err(ParseResourceTypeError(s.to_string())), + } + } +} + +#[derive(Debug)] +pub struct ParseResourceTypeError(String); + +impl fmt::Display for ParseResourceTypeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid Resource Type string: '{}'", self.0) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for ParseResourceTypeError {} diff --git a/crates/wallet/crypto/Cargo.toml b/crates/wallet/crypto/Cargo.toml index 43646c7d83..3d72b3d3b6 100644 --- a/crates/wallet/crypto/Cargo.toml +++ b/crates/wallet/crypto/Cargo.toml @@ -16,10 +16,13 @@ tari_utilities = { workspace = true } blake2 = { workspace = true } chacha20poly1305 = { workspace = true } +argon2 = "0.5.3" +crc32fast = "1.5.0" +subtle = "2.6.1" digest = { workspace = true } rand = { workspace = true } thiserror = { workspace = true } -zeroize = { workspace = true } +zeroize = { workspace = true, features = ["simd"] } [dev-dependencies] tari_template_test_tooling = { workspace = true } diff --git a/crates/wallet/crypto/src/encryption.rs b/crates/wallet/crypto/src/encryption.rs new file mode 100644 index 0000000000..683e713d97 --- /dev/null +++ b/crates/wallet/crypto/src/encryption.rs @@ -0,0 +1,297 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use argon2::password_hash::rand_core::RngCore; +use chacha20poly1305::{AeadInPlace, Key, KeyInit, Nonce, Tag}; +use rand::rngs::OsRng; +use subtle::ConstantTimeEq; +use tari_crypto::tari_utilities::safe_array::SafeArray; +use zeroize::Zeroizing; + +use crate::hashers::{OotleWalletHashDomain, OotleWalletHasher32}; + +/// The version should be incremented for any breaking change to the format +/// NOTE: Only the most recent version is supported! +/// History: +/// 0: initial version +const ENCRYPTION_VERSION: u8 = 0u8; + +const ENCRYPTED_DATA_TAG: &[u8] = b"TARI_WALLET_EXTEND_NONCE_VARIANT"; + +// Fixed sizes (all in bytes) +const TAG_SIZE: usize = size_of::(); +const SALT_LENGTH: usize = 5; +const ARGON2_SALT_BYTES: usize = 16; +const ENCRYPTION_KEY_BYTES_LEN: usize = 32; +const MAC_KEY_BYTE_LEN: usize = 32; +const CHECKSUM_LENGTH: usize = size_of::(); +const KEY_BYTES: usize = ENCRYPTION_KEY_BYTES_LEN + MAC_KEY_BYTE_LEN; + +pub fn decrypt_with_password(cipher_text: &[u8], passphrase: &[u8]) -> Result>, CipherError> { + const CIPHERTEXT_MIN_LEN: usize = 1 + MAC_KEY_BYTE_LEN + SALT_LENGTH + TAG_SIZE + CHECKSUM_LENGTH; + if cipher_text.len() < CIPHERTEXT_MIN_LEN { + return Err(CipherError("Ciphertext too short".to_string())); + } + + // We only support one version right now + let version = *cipher_text.first().expect("cipher_text is not empty"); + if version != ENCRYPTION_VERSION { + return Err(CipherError(format!( + "Unsupported ciphertext version: {version}, expected: {}", + ENCRYPTION_VERSION + ))); + } + + let len = cipher_text.len(); + // Verify the checksum first, to detect obvious errors + let (cipher_payload, checksum) = cipher_text + .split_at_checked(len - CHECKSUM_LENGTH) + .ok_or_else(|| CipherError("Ciphertext too short (checksum)".to_string()))?; + let checksum = u32::from_le_bytes( + copy_fixed_checked(checksum) + .ok_or_else(|| CipherError(format!("Invalid checksum length {}", checksum.len())))?, + ); + let expected_checksum = crc32fast::hash(cipher_payload); + if checksum != expected_checksum { + return Err(CipherError("Ciphertext checksum mismatch".to_string())); + } + + // Derive encryption and MAC keys from passphrase and main salt + let len = cipher_payload.len(); + let (cipher_payload, salt) = cipher_payload + .split_at_checked(len - SALT_LENGTH) + .ok_or_else(|| CipherError("Ciphertext too short (salt)".to_string()))?; + let salt: [u8; SALT_LENGTH] = copy_fixed_checked(salt).expect("Salt length is SALT_LENGTH"); + let key = derive_keys(passphrase, salt.as_slice())?; + let (encryption_key, mac_key) = key.split_at(ENCRYPTION_KEY_BYTES_LEN); + + // Split off the tag, which is at a fixed position from the end + let len = cipher_payload.len(); + let (cipher_payload, tag) = cipher_payload + .split_at_checked(len - TAG_SIZE) + .ok_or_else(|| CipherError("Ciphertext too short (tag)".to_string()))?; + let tag = Tag::from_slice(tag); + + // Decrypt the secret data: payload and MAC (without leading version byte) + let mut decrypted_payload = Zeroizing::new(cipher_payload[1..].to_vec()); + let nonce = encryption_nonce_hasher().chain(&salt).finalize(); + let nonce = nonce + .as_slice() + .get(..size_of::()) + .expect("Size of Nonce is greater than 32 bytes"); + let nonce = Nonce::from_slice(nonce); + decrypt(&mut decrypted_payload, encryption_key, nonce, tag)?; + + // Verify the MAC + let len = decrypted_payload.len(); + let mac = Zeroizing::new(decrypted_payload.split_off(len - MAC_KEY_BYTE_LEN)); + + // Generate the MAC + let expected_mac = generate_mac(version, &decrypted_payload, salt, mac_key); + + // Verify the MAC in constant time to avoid leaking information + if mac.ct_eq(&expected_mac).into() { + Ok(decrypted_payload) + } else { + Err(CipherError("Ciphertext MAC mismatch".to_string())) + } +} + +pub fn encrypt_with_password(plain_text: &[u8], passphrase: &[u8]) -> Result, CipherError> { + let mut salt = [0u8; SALT_LENGTH]; + OsRng.fill_bytes(salt.as_mut()); + let key = derive_keys(passphrase, salt.as_slice())?; + let (encryption_key, mac_key) = key.split_at(ENCRYPTION_KEY_BYTES_LEN); + + // Generate the MAC + let mac = generate_mac(ENCRYPTION_VERSION, plain_text, salt, mac_key); + let mut encrypted_buf = + Vec::with_capacity(1 + plain_text.len() + MAC_KEY_BYTE_LEN + SALT_LENGTH + TAG_SIZE + CHECKSUM_LENGTH); + + // Assemble the secret data to be encrypted: birthday, entropy, MAC + encrypted_buf.push(ENCRYPTION_VERSION); + encrypted_buf.extend_from_slice(plain_text); + encrypted_buf.extend_from_slice(&mac); + + // Derive Nonce from the salt + let nonce = encryption_nonce_hasher().chain(&salt).finalize(); + let nonce = nonce + .as_slice() + .get(..size_of::()) + .expect("Size of Nonce is greater than 32 bytes"); + let nonce = Nonce::from_slice(nonce); + + // Encrypt the secret data + let tag = encipher(&mut encrypted_buf[1..], encryption_key, nonce)?; + + // Append the tag, salt and checksum + encrypted_buf.extend_from_slice(tag.as_slice()); + encrypted_buf.extend_from_slice(salt.as_slice()); + let checksum = crc32fast::hash(encrypted_buf.as_slice()).to_le_bytes(); + encrypted_buf.extend_from_slice(&checksum); + + Ok(encrypted_buf) +} + +/// Encrypt data using ChaCha20Poly1305 and append the tag +fn encipher(data: &mut [u8], encryption_key: &[u8], nonce: &Nonce) -> Result { + // Encrypt the data + let cipher = chacha20poly1305::ChaCha20Poly1305::new(Key::from_slice(encryption_key)); + let tag = cipher + .encrypt_in_place_detached(nonce, ENCRYPTED_DATA_TAG, data) + .map_err(|e| CipherError(format!("Unable to apply stream cipher: {e}")))?; + + Ok(tag) +} + +fn decrypt(data: &mut [u8], encryption_key: &[u8], nonce: &Nonce, tag: &Tag) -> Result<(), CipherError> { + let cipher = chacha20poly1305::ChaCha20Poly1305::new(Key::from_slice(encryption_key)); + cipher + .decrypt_in_place_detached(nonce, ENCRYPTED_DATA_TAG, data, tag) + .map_err(|_| CipherError("Unable to decrypt data".to_string())) +} + +/// Use Argon2 to derive encryption key (first 32 bytes) and MAC key (last 32 bytes) from a passphrase and main salt +fn derive_keys(passphrase: &[u8], salt: &[u8]) -> Result, CipherError> { + // The Argon2 salt is derived from the main salt + let argon2_salt = encryption_salt_hasher().chain(salt).finalize(); + let argon2_salt = argon2_salt + .get(..ARGON2_SALT_BYTES) + .expect("ARGON2_SALT_BYTES < length of 32 byte blake hash"); + + // Run Argon2 with enough output to accommodate both keys, so we only run it once + let mut main_key = SafeArray::::default(); + // We use the recommended OWASP parameters for this: + // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id + let params = argon2::Params::new( + 46 * 1024, // m-cost = 46 MiB = 46 * 1024 KiB + 1, // t-cost + 1, // p-cost + Some(KEY_BYTES), + ) + .expect("Incorrect Argon2 parameters"); + + // Derive the main key from the password in place + let hasher = argon2::Argon2::new(argon2::Algorithm::Argon2d, argon2::Version::V0x13, params); + hasher + .hash_password_into(passphrase, argon2_salt, main_key.as_mut()) + .map_err(|_| CipherError("Problem generating Argon2 password hash".to_string()))?; + + Ok(main_key) +} + +/// Generate a MAC using Blake2b +fn generate_mac(version: u8, plain_text: &[u8], salt: [u8; SALT_LENGTH], mac_key: &[u8]) -> [u8; MAC_KEY_BYTE_LEN] { + encryption_mac_hasher() + .chain(&version) + .chain(plain_text) + .chain(&salt) + .chain(mac_key) + .finalize() + .into() +} + +pub fn encryption_mac_hasher() -> OotleWalletHasher32 { + OotleWalletHasher32::new_with_label("encryption_mac") +} +pub fn encryption_nonce_hasher() -> OotleWalletHasher32 { + OotleWalletHasher32::new_with_label("encryption_nonce") +} +pub fn encryption_salt_hasher() -> OotleWalletHasher32 { + OotleWalletHasher32::new_with_label("encryption_salt") +} + +#[derive(Debug, thiserror::Error)] +#[error("Cipher error: {0}")] +pub struct CipherError(String); + +fn copy_fixed_checked(bytes: &[u8]) -> Option<[u8; SZ]> { + if bytes.len() != SZ { + return None; + } + let mut out = [0u8; SZ]; + out.copy_from_slice(bytes); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_encrypts_and_decrypts() { + let password = b"correct horse battery staple"; + let data = b"The quick brown fox jumps over the lazy dog"; + + let encrypted = encrypt_with_password(data, password).expect("encryption failed"); + let decrypted = decrypt_with_password(&encrypted, password).expect("decryption failed"); + + assert_eq!(&*decrypted, data); + } + + #[test] + fn it_fails_for_invalid_checksum() { + let password = b"correct horse battery staple"; + let data = b"The quick brown fox jumps over the lazy dog"; + + let mut encrypted = encrypt_with_password(data, password).expect("encryption failed"); + // Corrupt the last byte (part of the checksum) + let last_index = encrypted.len() - 1; + encrypted[last_index] ^= 0xFF; + + let result = decrypt_with_password(&encrypted, password); + assert!(result.is_err()); + if let Err(e) = result { + assert_eq!(e.0, "Ciphertext checksum mismatch"); + } + } + + #[test] + fn it_fails_for_invalid_version() { + let password = b"correct horse battery staple"; + let data = b"The quick brown fox jumps over the lazy dog"; + + let mut encrypted = encrypt_with_password(data, password).expect("encryption failed"); + // Corrupt the version byte + encrypted[0] = 255; + + let result = decrypt_with_password(&encrypted, password); + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.0.starts_with("Unsupported ciphertext version")); + } + } + + #[test] + fn it_fails_for_invalid_length() { + let password = b"correct horse battery staple"; + let data = b"The quick brown fox jumps over the lazy dog"; + + let encrypted = encrypt_with_password(data, password).expect("encryption failed"); + // Truncate the encrypted data to make it invalid + let truncated = &encrypted[..5]; + + let result = decrypt_with_password(truncated, password); + assert!(result.is_err()); + if let Err(e) = result { + assert_eq!(e.0, "Ciphertext too short"); + } + } + + #[test] + fn it_fails_for_corrupted_payload_data() { + let password = b"correct horse battery staple"; + let data = b"The quick brown fox jumps over the lazy dog"; + + let mut encrypted = encrypt_with_password(data, password).expect("encryption failed"); + // Corrupt a byte in the payload + let salt_start = encrypted.len() - SALT_LENGTH - CHECKSUM_LENGTH - TAG_SIZE; + encrypted[salt_start] ^= 0xFF; + + let result = decrypt_with_password(&encrypted, password); + assert!(result.is_err()); + if let Err(e) = result { + assert_eq!(e.0, "Ciphertext checksum mismatch"); + } + } +} diff --git a/crates/wallet/crypto/src/hashers.rs b/crates/wallet/crypto/src/hashers.rs index f139cdc2cc..00d1aa34a8 100644 --- a/crates/wallet/crypto/src/hashers.rs +++ b/crates/wallet/crypto/src/hashers.rs @@ -2,14 +2,15 @@ // SPDX-License-Identifier: BSD-3-Clause use blake2::Blake2b; -use digest::consts::U64; +use digest::consts::{U32, U64}; use tari_crypto::hash_domain; use tari_hashing::DomainSeparatedBorshHasher; use tari_ootle_common_types::Network; hash_domain!(OotleWalletHashDomain, "com.tari.ootle.wallet", 1); +pub type OotleWalletHasher32 = DomainSeparatedBorshHasher>; pub type OotleWalletHasher64 = DomainSeparatedBorshHasher>; -fn wallet_hasher64(network: Network, label: &'static str) -> OotleWalletHasher64 { +pub(crate) fn wallet_hasher64(network: Network, label: &'static str) -> OotleWalletHasher64 { OotleWalletHasher64::new_with_label(&format!("{}.n{}", label, network.as_byte())) } diff --git a/crates/wallet/crypto/src/lib.rs b/crates/wallet/crypto/src/lib.rs index af4f14549a..9bdeb3f521 100644 --- a/crates/wallet/crypto/src/lib.rs +++ b/crates/wallet/crypto/src/lib.rs @@ -12,6 +12,7 @@ pub mod stealth; mod unblinded_statement; mod value_lookup; +pub mod encryption; pub mod viewable_balance_proof; pub use error::*; diff --git a/crates/wallet/sdk/Cargo.toml b/crates/wallet/sdk/Cargo.toml index 47b6f14099..7a669f40eb 100644 --- a/crates/wallet/sdk/Cargo.toml +++ b/crates/wallet/sdk/Cargo.toml @@ -33,7 +33,7 @@ ts-rs = { workspace = true, optional = true } webauthn-rs = { workspace = true } keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "sync-secret-service"] } passwords = "3.1.16" -zeroize = { workspace = true } +zeroize = { workspace = true, features = ["serde", "simd"] } [dev-dependencies] tari_ootle_wallet_storage_sqlite = { workspace = true } diff --git a/crates/wallet/sdk/src/apis/accounts.rs b/crates/wallet/sdk/src/apis/accounts.rs index 22b6761d27..55ae5663d9 100644 --- a/crates/wallet/sdk/src/apis/accounts.rs +++ b/crates/wallet/sdk/src/apis/accounts.rs @@ -6,31 +6,44 @@ use std::collections::HashSet; use tari_engine_types::{ component::derive_component_address_from_public_key, indexed_value::IndexedWellKnownTypes, + FromByteType, ToByteType, }; +use tari_ootle_address::RistrettoOotleAddress; use tari_ootle_common_types::{ optional::{IsNotFoundError, Optional}, substate_type::SubstateType, + Network, }; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::{ models::{ComponentAddress, ResourceAddress, VaultId}, - prelude::{ResourceType, RistrettoPublicKeyBytes}, + prelude::{ResourceType, RistrettoPublicKeyBytes, XTR}, types::Amount, }; use crate::{ apis::{ confidential_transfer::{ConfidentialTransferApiError, ResolvedAccountDetails}, - key_manager::{KeyBranch, KeyManagerApi, KeyManagerApiError}, + key_manager::{KeyManagerApi, KeyManagerApiError}, substate::{SubstatesApi, ValidatorScanResult}, }, - models::{Account, AccountUpdate, AccountWithAddress, VaultBalance, VaultModel}, + models::{ + Account, + AccountUpdate, + AccountWithAddress, + KeyId, + KeyIdOrPublicKey, + VaultBalance, + VaultModel, + WalletOotleAddressWithKeyIds, + }, network::WalletNetworkInterface, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, }; pub struct AccountsApi<'a, TStore, TNetworkInterface> { + network: Network, store: &'a TStore, substates_api: SubstatesApi<'a, TStore, TNetworkInterface>, key_manager_api: KeyManagerApi<'a, TStore>, @@ -42,11 +55,13 @@ pub fn derive_account_address_from_public_key(public_key: &RistrettoPublicKeyByt impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetworkInterface> { pub fn new( + network: Network, store: &'a TStore, substates_api: SubstatesApi<'a, TStore, TNetworkInterface>, key_manager_api: KeyManagerApi<'a, TStore>, ) -> Self { Self { + network, store, substates_api, key_manager_api, @@ -61,61 +76,71 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor &self, account_name: Option<&str>, is_default: bool, - key_id: Option, + account_address: WalletOotleAddressWithKeyIds, ) -> Result { - let owner_address = match key_id { - Some(id) => self.key_manager_api.derive_account_address(id)?, - None => self.key_manager_api.next_account_address()?, - }; - let owner_public_key = owner_address.address.account_key().to_byte_type(); - let account_component_address = derive_account_address_from_public_key(&owner_public_key); - - self.store.with_write_tx(|tx| { - if let Some(name) = account_name { - if tx.accounts_get_by_name(name).optional()?.is_some() { - return Err(AccountsApiError::AccountNameAlreadyExists { name: name.to_string() }); - } - } + let account_public_key = account_address.address.account_key().to_byte_type(); + let account_component_address = derive_account_address_from_public_key(&account_public_key); + + self.add_account( + account_name, + &account_component_address, + account_address.view_only_key_id, + account_address.owner_key_id, + false, + is_default, + )?; - tx.accounts_insert( - account_name, - &account_component_address, - owner_address.key_index, - false, + Ok(AccountWithAddress { + account: Account { + name: account_name.map(String::from), + component_address: account_component_address, + view_only_key_id: account_address.view_only_key_id, + owner_key_id: Some(account_address.owner_key_id), + owner_public_key: Default::default(), + is_confirmed_on_chain: false, is_default, - )?; - Ok(AccountWithAddress { - account: Account { - name: account_name.map(String::from), - component_address: account_component_address, - key_index: owner_address.key_index, - is_confirmed_on_chain: false, - is_default, - }, - address: owner_address.address.to_byte_type(), - }) + }, + address: account_address.address.to_byte_type(), }) } - pub fn add_account( + pub fn add_account>( &self, account_name: Option<&str>, account_address: &ComponentAddress, - owner_key_index: u64, + view_only_key_id: KeyId, + owner_key: K, is_confirmed_on_chain: bool, is_default: bool, ) -> Result<(), AccountsApiError> { + let (owner_pk, owner_key_id) = match owner_key.into() { + KeyIdOrPublicKey::KeyId(key_id) => { + let pk = self + .key_manager_api + .get_account_owner_key(key_id)? + .to_public_key() + .to_byte_type(); + (pk, Some(key_id)) + }, + KeyIdOrPublicKey::PublicKey(pk) => (pk, None), + }; self.store.with_write_tx(|tx| { if let Some(name) = account_name { if tx.accounts_get_by_name(name).optional()?.is_some() { return Err(AccountsApiError::AccountNameAlreadyExists { name: name.to_string() }); } } - tx.key_manager_insert_or_ignore(KeyBranch::Account.as_str(), owner_key_index)?; + + let mut associated_stealth_resources = HashSet::new(); + associated_stealth_resources.insert(XTR); + tx.accounts_insert( account_name, account_address, - owner_key_index, + view_only_key_id, + owner_key_id, + &owner_pk, + &associated_stealth_resources, is_confirmed_on_chain, is_default, )?; @@ -134,20 +159,17 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor }) } - pub fn associate_stealth_resource( - &self, - account_address: &ComponentAddress, - stealth_resource_address: ResourceAddress, - ) -> Result<(), AccountsApiError> { - self.store - .with_write_tx(|tx| tx.accounts_add_stealth_resource(account_address, stealth_resource_address))?; - Ok(()) - } - - pub fn get_many(&self, offset: u64, limit: u64) -> Result, AccountsApiError> { - let mut tx = self.store.create_read_tx()?; - let accounts = tx.accounts_get_many(offset, limit)?; - Ok(accounts) + pub fn get_many(&self, offset: u64, limit: u64) -> Result, AccountsApiError> { + let accounts = self.store.with_read_tx(|tx| tx.accounts_get_many(offset, limit))?; + accounts + .into_iter() + .map(|a| { + self.get_address_for_account(&a).map(|address| AccountWithAddress { + account: a, + address: address.to_byte_type(), + }) + }) + .collect() } pub fn count(&self) -> Result { @@ -161,22 +183,20 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor } pub fn get_default(&self) -> Result { - // TODO: be careful not to use the key manager with a read transaction open as this will deadlock. The DB - // transaction should be passed into the SDK methods to avoid this. let account = self.store.with_read_tx(|tx| tx.accounts_get_default())?; - let address = self.key_manager_api.derive_account_address(account.key_index)?; + let address = self.get_address_for_account(&account)?; Ok(AccountWithAddress { account, - address: address.address.to_byte_type(), + address: address.to_byte_type(), }) } pub fn get_account_by_name(&self, name: &str) -> Result { let account = self.store.with_read_tx(|tx| tx.accounts_get_by_name(name))?; - let address = self.key_manager_api.derive_account_address(account.key_index)?; + let address = self.get_address_for_account(&account)?; Ok(AccountWithAddress { account, - address: address.address.to_byte_type(), + address: address.to_byte_type(), }) } @@ -206,13 +226,48 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor pub fn get_account_by_address(&self, address: &ComponentAddress) -> Result { let account = self.store.with_read_tx(|tx| tx.accounts_get(address))?; - let address = self.key_manager_api.derive_account_address(account.key_index)?; + let address = self.get_address_for_account(&account)?; Ok(AccountWithAddress { account, - address: address.address.to_byte_type(), + address: address.to_byte_type(), }) } + fn get_address_for_account(&self, account: &Account) -> Result { + let view_only_key = match account.view_only_key_id { + KeyId::Derived { index } => { + let view_only_key = self.key_manager_api.derive_view_only_key(index)?; + view_only_key.to_public_key() + }, + KeyId::Imported { local_key_id } => { + let imported = self.key_manager_api.get_imported_key(local_key_id)?; + imported.to_public_key() + }, + }; + let address = RistrettoOotleAddress::new( + self.network, + view_only_key, + account + .owner_public_key() + .try_from_byte_type() + .map_err(|e| WalletStorageError::DataInconsistent { + operation: "get_address_for_account", + details: format!("Failed to convert owner public key from byte type: {e}"), + })?, + ); + Ok(address) + } + + pub fn associate_stealth_resource( + &self, + account_address: &ComponentAddress, + stealth_resource_address: ResourceAddress, + ) -> Result<(), AccountsApiError> { + self.store + .with_write_tx(|tx| tx.accounts_add_stealth_resource(account_address, stealth_resource_address))?; + Ok(()) + } + pub fn get_associated_stealth_resources( &self, address: &ComponentAddress, @@ -229,10 +284,10 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor ) -> Result { let account_address = derive_account_address_from_public_key(public_key); let account = self.store.with_read_tx(|tx| tx.accounts_get(&account_address))?; - let address = self.key_manager_api.derive_account_address(account.key_index)?; + let address = self.get_address_for_account(&account)?; Ok(AccountWithAddress { account, - address: address.address.to_byte_type(), + address: address.to_byte_type(), }) } diff --git a/crates/wallet/sdk/src/apis/confidential_outputs.rs b/crates/wallet/sdk/src/apis/confidential_outputs.rs index 01388232f2..925267f95f 100644 --- a/crates/wallet/sdk/src/apis/confidential_outputs.rs +++ b/crates/wallet/sdk/src/apis/confidential_outputs.rs @@ -8,7 +8,6 @@ use tari_ootle_common_types::optional::{IsNotFoundError, Optional}; use tari_ootle_wallet_crypto::{kdfs, MaskAndValue}; use tari_template_lib::{models::VaultId, prelude::PedersenCommitmentBytes, types::Amount}; use tari_transaction::TransactionId; -use tari_transaction_components::key_manager::tari_key_manager::DerivedKey; use crate::{ apis::{ @@ -16,7 +15,7 @@ use crate::{ confidential_crypto::{ConfidentialCryptoApi, ConfidentialCryptoApiError}, key_manager::{KeyManagerApi, KeyManagerApiError}, }, - models::{Account, ConfidentialOutputModel, OutputStatus, WalletLockId}, + models::{Account, ConfidentialOutputModel, Key, OutputStatus, WalletLockId}, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, }; @@ -94,7 +93,9 @@ where TStore: WalletStore let mut total_output_amount = Amount::zero(); let mut outputs = Vec::new(); while total_output_amount < amount { - let output = tx.outputs_lock_smallest_amount(vault_id, lock_id).optional()?; + let output = tx + .confidential_outputs_lock_smallest_amount(vault_id, lock_id) + .optional()?; match output { Some(output) => { total_output_amount += output.value; @@ -111,7 +112,7 @@ where TStore: WalletStore pub fn add_output(&self, output: ConfidentialOutputModel) -> Result<(), ConfidentialOutputsApiError> { let mut tx = self.store.create_write_tx()?; - tx.outputs_insert(output)?; + tx.confidential_outputs_insert(output)?; tx.commit()?; Ok(()) } @@ -134,7 +135,7 @@ where TStore: WalletStore pub fn release_locked_outputs(&self, lock_id: WalletLockId) -> Result<(), ConfidentialOutputsApiError> { let mut tx = self.store.create_write_tx()?; - tx.outputs_release_by_lock_id(lock_id)?; + tx.confidential_outputs_release_by_lock_id(lock_id)?; tx.locks_delete(lock_id)?; tx.commit()?; Ok(()) @@ -142,7 +143,7 @@ where TStore: WalletStore pub fn finalize_outputs_for_lock(&self, lock_id: WalletLockId) -> Result<(), ConfidentialOutputsApiError> { let mut tx = self.store.create_write_tx()?; - tx.outputs_finalize_by_lock_id(lock_id)?; + tx.confidential_outputs_finalize_by_lock_id(lock_id)?; tx.locks_delete(lock_id)?; tx.commit()?; Ok(()) @@ -155,9 +156,7 @@ where TStore: WalletStore let mut outputs_with_masks = Vec::with_capacity(outputs.len()); for output in outputs { // Encryption is always done with a DH of the account's public key - let encryption_key = self - .key_manager_api - .derive_account_key(output.encryption_secret_key_index)?; + let encryption_key = self.key_manager_api.get_view_only_key(output.view_only_key_id)?; // Either derive the mask from the sender's public nonce or from the local key manager let shared_decrypt_key = match output.sender_public_nonce { Some(nonce) => { @@ -172,11 +171,11 @@ where TStore: WalletStore })?; // Derive shared secret - kdfs::encrypted_data_dh_kdf_aead(&encryption_key.key, &nonce) + kdfs::encrypted_data_dh_kdf_aead(&encryption_key.secret, &nonce) }, None => { // Use local secret - encryption_key.key + encryption_key.secret }, }; @@ -212,7 +211,7 @@ where TStore: WalletStore pub fn get_unspent_balance(&self, vault_id: &VaultId) -> Result { let mut tx = self.store.create_read_tx()?; - let balance = tx.outputs_get_unspent_balance(vault_id)?; + let balance = tx.confidential_outputs_get_unspent_balance(vault_id)?; Ok(balance.into()) } @@ -225,12 +224,14 @@ where TStore: WalletStore vault_id: VaultId, outputs: I, ) -> Result<(), ConfidentialOutputsApiError> { - // We do not support changing of account key at this time - let key = self.key_manager_api.derive_account_key(account.key_index)?; + let view_key = self.key_manager_api.get_view_only_key(account.view_only_key_id)?; let mut tx = self.store.create_write_tx()?; for (commitment, output) in outputs { - match tx.outputs_get_by_commitment(&vault_id, commitment).optional()? { + match tx + .confidential_outputs_get_by_commitment(&vault_id, commitment) + .optional()? + { Some(_) => { info!( target: LOG_TARGET, @@ -241,9 +242,9 @@ where TStore: WalletStore }, None => { // Output does not exist. Add it to the store - match self.validate_output(account, &key, vault_id, *commitment, output) { + match self.validate_output(account, &view_key, vault_id, *commitment, output) { Ok(output) => { - tx.outputs_insert(output)?; + tx.confidential_outputs_insert(output)?; }, Err(e) => { warn!( @@ -265,7 +266,7 @@ where TStore: WalletStore fn validate_output( &self, account: &Account, - key: &DerivedKey, + key: &Key, vault_id: VaultId, commitment: PedersenCommitmentBytes, output: &PrivateOutput, @@ -289,7 +290,7 @@ where TStore: WalletStore let unblinded_result = self.crypto_api.unblind_output( &commitment, &output.encrypted_data, - &key.key, + &key.secret, &output_stealth_public_nonce, ); let (value, status) = match unblinded_result { @@ -311,7 +312,8 @@ where TStore: WalletStore commitment, value, sender_public_nonce: Some(output_stealth_public_nonce.to_byte_type()), - encryption_secret_key_index: key.key_index, + view_only_key_id: key.key_id, + owner_key_id: account.owner_key_id, encrypted_data: output.encrypted_data.clone(), public_asset_tag: None, status, diff --git a/crates/wallet/sdk/src/apis/confidential_transfer.rs b/crates/wallet/sdk/src/apis/confidential_transfer.rs index a5ad2ad990..caac8437ec 100644 --- a/crates/wallet/sdk/src/apis/confidential_transfer.rs +++ b/crates/wallet/sdk/src/apis/confidential_transfer.rs @@ -36,7 +36,7 @@ const LOG_TARGET: &str = "tari::ootle::wallet_sdk::apis::confidential_transfers" pub struct ConfidentialTransferApi<'a, TStore, TNetworkInterface> { key_manager_api: KeyManagerApi<'a, TStore>, accounts_api: AccountsApi<'a, TStore, TNetworkInterface>, - outputs_api: ConfidentialOutputsApi<'a, TStore>, + confidential_outputs_api: ConfidentialOutputsApi<'a, TStore>, substate_api: SubstatesApi<'a, TStore, TNetworkInterface>, crypto_api: ConfidentialCryptoApi, config_api: ConfigApi<'a, TStore>, @@ -51,7 +51,7 @@ where pub fn new( key_manager_api: KeyManagerApi<'a, TStore>, accounts_api: AccountsApi<'a, TStore, TNetworkInterface>, - outputs_api: ConfidentialOutputsApi<'a, TStore>, + confidential_outputs_api: ConfidentialOutputsApi<'a, TStore>, substate_api: SubstatesApi<'a, TStore, TNetworkInterface>, crypto_api: ConfidentialCryptoApi, config_api: ConfigApi<'a, TStore>, @@ -59,7 +59,7 @@ where Self { key_manager_api, accounts_api, - outputs_api, + confidential_outputs_api, substate_api, crypto_api, config_api, @@ -84,9 +84,11 @@ where match &input_selection { ConfidentialTransferInputSelection::ConfidentialOnly => { let (confidential_inputs, _) = - self.outputs_api + self.confidential_outputs_api .lock_outputs_by_amount(lock_id, &src_vault.id, spend_amount)?; - let confidential_inputs = self.outputs_api.resolve_output_masks(confidential_inputs)?; + let confidential_inputs = self + .confidential_outputs_api + .resolve_output_masks(confidential_inputs)?; info!( target: LOG_TARGET, @@ -106,7 +108,7 @@ where return Err(ConfidentialTransferApiError::InsufficientFunds); } - self.outputs_api + self.confidential_outputs_api .lock_vault_revealed_funds(lock_id, &src_vault.id, spend_amount)?; info!( @@ -133,8 +135,11 @@ where src_vault.id, ); - self.outputs_api - .lock_vault_revealed_funds(lock_id, &src_vault.id, revealed_to_spend)?; + self.confidential_outputs_api.lock_vault_revealed_funds( + lock_id, + &src_vault.id, + revealed_to_spend, + )?; return Ok(InputsToSpend { confidential: vec![], @@ -143,14 +148,18 @@ where }); } - let (confidential_inputs, _) = - self.outputs_api - .lock_outputs_by_amount(lock_id, &src_vault.id, confidential_to_spend)?; - let confidential_inputs = self.outputs_api.resolve_output_masks(confidential_inputs)?; + let (confidential_inputs, _) = self.confidential_outputs_api.lock_outputs_by_amount( + lock_id, + &src_vault.id, + confidential_to_spend, + )?; + let confidential_inputs = self + .confidential_outputs_api + .resolve_output_masks(confidential_inputs)?; let total_confidential_spent = confidential_inputs.iter().map(|i| i.value).sum::(); - self.outputs_api + self.confidential_outputs_api .lock_vault_revealed_funds(lock_id, &src_vault.id, revealed_to_spend)?; info!( @@ -171,9 +180,9 @@ where }) }, ConfidentialTransferInputSelection::PreferConfidential => { - let (confidential_inputs, amount_locked) = - self.outputs_api - .lock_outputs_until_partial_amount(lock_id, &src_vault.id, spend_amount)?; + let (confidential_inputs, amount_locked) = self + .confidential_outputs_api + .lock_outputs_until_partial_amount(lock_id, &src_vault.id, spend_amount)?; let revealed_to_spend = spend_amount .saturating_sub_positive(amount_locked) @@ -183,10 +192,12 @@ where return Err(ConfidentialTransferApiError::InsufficientFunds); } - self.outputs_api + self.confidential_outputs_api .lock_vault_revealed_funds(lock_id, &src_vault.id, revealed_to_spend)?; - let confidential_inputs = self.outputs_api.resolve_output_masks(confidential_inputs)?; + let confidential_inputs = self + .confidential_outputs_api + .resolve_output_masks(confidential_inputs)?; Ok(InputsToSpend { confidential: confidential_inputs, @@ -208,6 +219,14 @@ where .resolve_account_by_public_key(params.destination_address.account_public_key()) .await?; + let account_owner_key_id = + from_account + .owner_key_id() + .ok_or_else(|| ConfidentialTransferApiError::InvalidParameter { + param: "from_account", + reason: "From account does not have an owner key".to_string(), + })?; + // Determine Transaction Inputs let mut inputs = Vec::new(); @@ -255,11 +274,11 @@ where // Reserve and lock input funds for fees let max_fee = params.max_fee; - let account_secret = self.key_manager_api.derive_account_key(account.key_index())?; - let account_public_key = PublicKey::from_secret_key(&account_secret.key); + let account_key = self.key_manager_api.get_account_owner_key(account_owner_key_id)?; + let account_public_key = PublicKey::from_secret_key(&account_key.secret); // Reserve and lock input funds - let lock_id = self.outputs_api.create_lock()?; + let lock_id = self.confidential_outputs_api.create_lock()?; let inputs_to_spend = match self.resolved_inputs_for_transfer( lock_id, params.from_account, @@ -323,7 +342,7 @@ where let change_value = statement.amount; if change_value.is_positive() { - self.outputs_api.add_output(ConfidentialOutputModel { + self.confidential_outputs_api.add_output(ConfidentialOutputModel { account_address: *account.component_address(), vault_id: src_vault.id, commitment: statement @@ -332,7 +351,8 @@ where .to_byte_type(), value: change_value, sender_public_nonce: Some(statement.sender_public_nonce.to_byte_type()), - encryption_secret_key_index: account_secret.key_index, + view_only_key_id: account_key.key_id, + owner_key_id: Some(account_key.key_id), encrypted_data: statement.encrypted_data.clone(), public_asset_tag: None, status: OutputStatus::LockedUnconfirmed, @@ -390,10 +410,10 @@ where } }) .with_inputs(inputs) - .build_and_seal(&account_secret.key); + .build_and_seal(&account_key.secret); let tx_id = transaction.calculate_id(); - self.outputs_api + self.confidential_outputs_api .locks_set_transaction_id(inputs_to_spend.lock_id, tx_id)?; Ok(TransferOutput { diff --git a/crates/wallet/sdk/src/apis/config.rs b/crates/wallet/sdk/src/apis/config.rs index a4985e735b..d8888e8c05 100644 --- a/crates/wallet/sdk/src/apis/config.rs +++ b/crates/wallet/sdk/src/apis/config.rs @@ -8,7 +8,7 @@ use tari_ootle_common_types::{optional::IsNotFoundError, Network}; use crate::storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ConfigApi<'a, TStore> { store: &'a TStore, cached_network: OnceLock, @@ -37,8 +37,20 @@ impl<'a, TStore: WalletStore> ConfigApi<'a, TStore> { Ok(network) } - pub fn get(&self, key: ConfigKey) -> Result - where T: DeserializeOwned { + pub fn get(&self, key: ConfigKey) -> Result { + let mut tx = self.store.create_read_tx()?; + let record = tx.config_get(key.as_key_str())?; + if record.is_encrypted { + return Err(ConfigApiError::EncryptedItem { key }); + } + Ok(record.value) + } + + pub fn get_decrypted( + &self, + key: ConfigKey, + _decryption_key: impl AsRef<[u8]>, + ) -> Result { let mut tx = self.store.create_read_tx()?; let record = tx.config_get(key.as_key_str())?; // TODO: decryption if record.is_encrypted @@ -51,20 +63,34 @@ impl<'a, TStore: WalletStore> ConfigApi<'a, TStore> { Ok(exists) } - pub fn set( + pub fn set(&self, key: ConfigKey, value: &T) -> Result<(), ConfigApiError> { + self.set_opts(key, value, false) + } + + pub fn set_encrypted( + &self, + key: ConfigKey, + value: &T, + _encryption_key: impl AsRef<[u8]>, + ) -> Result<(), ConfigApiError> { + // TODO: encrypt + self.set_opts(key, value, true) + } + + fn set_opts( &self, key: ConfigKey, value: &T, is_encrypted: bool, ) -> Result<(), ConfigApiError> { let mut tx = self.store.create_write_tx()?; - // TODO: Actually encrypt if is_encrypted is true tx.config_set(key.as_key_str(), value, is_encrypted)?; tx.commit()?; Ok(()) } } +#[derive(Debug, Clone, Copy)] pub enum ConfigKey { /// The network the wallet is running on. type: String Network, @@ -96,6 +122,8 @@ pub enum ConfigApiError { StoreError(#[from] WalletStorageError), #[error("Failed to parse network string '{string}': {details}")] FailedToParseNetwork { string: String, details: String }, + #[error("The requested item is encrypted and cannot be retrieved without decryption: {key:?}")] + EncryptedItem { key: ConfigKey }, } impl IsNotFoundError for ConfigApiError { diff --git a/crates/wallet/sdk/src/apis/key_manager.rs b/crates/wallet/sdk/src/apis/key_manager.rs index 42441ac8e2..8b9f41b322 100644 --- a/crates/wallet/sdk/src/apis/key_manager.rs +++ b/crates/wallet/sdk/src/apis/key_manager.rs @@ -2,29 +2,42 @@ // SPDX-License-Identifier: BSD-3-Clause use blake2::Blake2b; -use digest::consts::U64; +use digest::{consts::U64, crypto_common::rand_core::OsRng}; use tari_bor::{Deserialize, Serialize}; -use tari_common_types::seeds::cipher_seed::CipherSeed; -use tari_crypto::{keys::PublicKey as _, ristretto::RistrettoPublicKey, tari_utilities::ByteArray}; +use tari_crypto::{ + keys::{PublicKey as _, SecretKey}, + ristretto::{RistrettoPublicKey, RistrettoSecretKey}, + tari_utilities::ByteArray, +}; use tari_ootle_address::RistrettoOotleAddress; use tari_ootle_common_types::{ optional::{IsNotFoundError, Optional}, Network, }; -use tari_template_lib::types::crypto::RistrettoPublicKeyBytes; -use tari_transaction_components::{ - key_manager, - key_manager::tari_key_manager::{DerivedKey, TariKeyManager}, -}; +use tari_ootle_wallet_crypto::encryption::{decrypt_with_password, encrypt_with_password}; +use tari_transaction_components::{key_manager, key_manager::tari_key_manager::TariKeyManager}; use crate::{ - models::{DerivedAddress, KeyPair, WalletKey}, + apis::password_manager::{PasswordManagerApi, PasswordManagerApiError}, + cipher_seed::WalletCipherSeed, + models::{ + DerivedKeyIndex, + DerivedKeyPair, + DerivedWalletKey, + ImportedKeyId, + ImportedWalletKey, + Key, + KeyId, + KeyType, + WalletKeyRecord, + WalletOotleAddressWithKeyIds, + }, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, }; pub type WalletKeyManager = TariKeyManager>; -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] #[serde(rename_all = "snake_case")] pub enum KeyBranch { @@ -66,18 +79,26 @@ impl AsRef for KeyBranch { } } +#[derive(Clone)] pub struct KeyManagerApi<'a, TStore> { network: Network, store: &'a TStore, - cipher_seed: &'a CipherSeed, + cipher_seed: &'a WalletCipherSeed, + password_manager: PasswordManagerApi<'a, TStore>, } impl<'a, TStore: WalletStore> KeyManagerApi<'a, TStore> { - pub(crate) fn new(network: Network, store: &'a TStore, cipher_seed: &'a CipherSeed) -> Self { + pub(crate) fn new( + network: Network, + store: &'a TStore, + cipher_seed: &'a WalletCipherSeed, + password_manager: PasswordManagerApi<'a, TStore>, + ) -> Self { Self { network, store, cipher_seed, + password_manager, } } @@ -92,102 +113,167 @@ impl<'a, TStore: WalletStore> KeyManagerApi<'a, TStore> { Ok(()) } - pub fn get_all_keys(&self, branch: KeyBranch) -> Result, KeyManagerApiError> { + pub fn get_all_derived_keys(&self, branch: KeyBranch) -> Result, KeyManagerApiError> { let all_keys = self.store.with_read_tx(|tx| tx.key_manager_get_all(branch.as_str()))?; let mut keys = Vec::with_capacity(all_keys.len()); - let km = self.get_key_manager(branch.as_str(), 0); + let km = self.get_key_manager(branch.as_str())?; for (index, active) in all_keys { let key = km .derive_key(index) .map_err(key_manager::error::KeyManagerServiceError::from)?; let pk = RistrettoPublicKey::from_secret_key(&key.key); - keys.push(WalletKey { - branch, - key_pair: KeyPair { - public_key: pk, - secret_key: key, - }, + keys.push(WalletKeyRecord { + key_id: KeyId::derived(index), + public_key: pk, + secret_key: key.key, is_active: active, }); } Ok(keys) } - pub fn derive_key(&self, branch: KeyBranch, index: u64) -> Result { - let km = self.get_key_manager(branch, 0); + pub fn get_imported_key(&self, id: ImportedKeyId) -> Result { + let password = self.password_manager.get_cipher_seed_password()?; + self.store.with_read_tx(|tx| { + let (key_type, encrypted_key) = tx.key_manager_get_raw_imported_key(id)?; + let decrypted = decrypt_with_password(&encrypted_key, password.reveal()).map_err(|e| { + KeyManagerApiError::StoreError(WalletStorageError::DecryptionError { + operation: "KeyManagerApi::get_imported_key", + details: format!("Failed to decrypt imported key: {}", e), + }) + })?; + Ok(ImportedWalletKey { + key: RistrettoSecretKey::from_canonical_bytes(&decrypted).map_err(|_| { + KeyManagerApiError::StoreError(WalletStorageError::DecodingError { + operation: "KeyManagerApi::get_imported_key", + item: "imported_key", + details: "Failed to decode imported key".to_string(), + }) + })?, + import_id: id, + key_type, + }) + }) + } + + pub fn import_key( + &self, + label: &str, + secret_key: &RistrettoSecretKey, + key_type: KeyType, + ) -> Result { + let password = self.password_manager.get_cipher_seed_password()?; + let encrypted_key = encrypt_with_password(secret_key.as_bytes(), password.reveal()).map_err(|e| { + KeyManagerApiError::StoreError(WalletStorageError::EncryptionError { + operation: "KeyManagerApi::import_key", + details: format!("Failed to encrypt imported key: {}", e), + }) + })?; + let id = self + .store + .with_write_tx(|tx| tx.key_manager_insert_imported_key(label, &encrypted_key, key_type))?; + Ok(KeyId::imported(id)) + } + + pub fn get_account_owner_key(&self, key_id: KeyId) -> Result { + self.get_key(KeyBranch::Account, key_id) + } + + pub fn get_view_only_key(&self, key_id: KeyId) -> Result { + self.get_key(KeyBranch::ViewOnlyKey, key_id) + } + + pub fn get_key(&self, branch: KeyBranch, key_id: KeyId) -> Result { + match key_id { + KeyId::Imported { local_key_id } => { + let imported_key = self.get_imported_key(local_key_id)?; + Ok(imported_key.into()) + }, + KeyId::Derived { index } => { + let derived_key = self.derive_key(branch, index)?; + Ok(derived_key.into()) + }, + } + } + + pub fn derive_key( + &self, + branch: KeyBranch, + index: DerivedKeyIndex, + ) -> Result { + let km = self.get_key_manager(branch)?; let key = km .derive_key(index) .expect("derive_key only panics if the hasher does not produce 32 bytes"); - Ok(key) + Ok(key.into()) } - pub fn derive_keypair(&self, branch: KeyBranch, index: u64) -> Result { - let key = self.derive_key(branch, index)?; - let public_key = RistrettoPublicKey::from_secret_key(&key.key); - Ok(KeyPair { - public_key, - secret_key: key, + pub fn derive_keypair( + &self, + branch: KeyBranch, + key_index: DerivedKeyIndex, + ) -> Result { + let derived_key = self.derive_key(branch, key_index)?; + Ok(DerivedKeyPair { + public_key: derived_key.to_public_key(), + derived_key, }) } - pub fn derive_account_keypair(&self, index: u64) -> Result<(DerivedKey, RistrettoPublicKey), KeyManagerApiError> { + pub fn derive_account_keypair( + &self, + index: u64, + ) -> Result<(DerivedWalletKey, RistrettoPublicKey), KeyManagerApiError> { let key = self.derive_account_key(index)?; let public_key = RistrettoPublicKey::from_secret_key(&key.key); Ok((key, public_key)) } - pub fn derive_account_key(&self, index: u64) -> Result { + pub fn derive_account_key(&self, index: DerivedKeyIndex) -> Result { self.derive_key(KeyBranch::Account, index) } - pub fn derive_account_address(&self, index: u64) -> Result { + pub fn derive_account_address( + &self, + index: DerivedKeyIndex, + ) -> Result { let key = self.derive_account_key(index)?; let view_only_key = self.derive_view_only_key(index)?; - Ok(DerivedAddress { + Ok(WalletOotleAddressWithKeyIds { address: RistrettoOotleAddress { network: self.network, view_only_key: RistrettoPublicKey::from_secret_key(&view_only_key.key), account_key: RistrettoPublicKey::from_secret_key(&key.key), }, - key_index: key.key_index, + view_only_key_id: key.as_key_id(), + owner_key_id: key.as_key_id(), }) } - pub fn next_account_address(&self) -> Result { + pub fn next_account_address(&self) -> Result { let key = self.next_key(KeyBranch::Account)?; - let view_only_key = self.derive_view_only_key(key.key_index)?; - let account_key = RistrettoPublicKey::from_secret_key(&key.key); - let view_only_key = RistrettoPublicKey::from_secret_key(&view_only_key.key); - - Ok(DerivedAddress { - address: RistrettoOotleAddress { - network: self.network, - view_only_key, - account_key, - }, - key_index: key.key_index, - }) + self.derive_account_address(key.key_index) } - pub fn derive_view_only_key(&self, index: u64) -> Result { + pub fn derive_view_only_key(&self, index: DerivedKeyIndex) -> Result { self.derive_key(KeyBranch::ViewOnlyKey, index) } - pub fn derive_view_only_keypair(&self, index: u64) -> Result { + pub fn derive_view_only_keypair(&self, index: u64) -> Result { let key = self.derive_view_only_key(index)?; let public_key = RistrettoPublicKey::from_secret_key(&key.key); - Ok(KeyPair { + Ok(DerivedKeyPair { public_key, - secret_key: key, + derived_key: key, }) } - pub fn derive_account_key_pair(&self, index: u64) -> Result { + pub fn derive_account_key_pair(&self, index: u64) -> Result { let key = self.derive_account_key(index)?; let public_key = RistrettoPublicKey::from_secret_key(&key.key); - Ok(KeyPair { + Ok(DerivedKeyPair { public_key, - secret_key: key, + derived_key: key, }) } @@ -198,24 +284,32 @@ impl<'a, TStore: WalletStore> KeyManagerApi<'a, TStore> { /// Derives the next key in the specified branch, increments the index, and sets it as the active key. /// If the branch does not exist, it will be created with index 0 and the first key will be returned. - /// NOTE: if there is another active DB transaction this function will block until it can acquire it. - pub fn next_key(&self, branch: KeyBranch) -> Result { + /// TODO: if there is another active DB transaction this function will block until it can acquire it. + pub fn next_key(&self, branch: KeyBranch) -> Result { let mut tx = self.store.create_write_tx()?; - let index = tx.key_manager_get_last_index(branch.as_str()).optional()?.unwrap_or(0); - let mut key_manager = WalletKeyManager::from(self.cipher_seed.clone(), branch.as_str().to_string(), index); + let next_index = tx + .key_manager_get_last_index(branch.as_str()) + .optional()? + .map(|i| i + 1) + .unwrap_or(0); + let key_manager = self.get_key_manager(branch.as_str())?; let key = key_manager - .next_key() + .derive_key(next_index) // TODO: Key manager shouldn't return other errors .map_err(key_manager::error::KeyManagerServiceError::from)?; // Index of account keys and view keys should always match to allow UTXO recovery when the specific account is // unknown if matches!(branch, KeyBranch::Account) { // Ensure the view key branch is created if it doesn't exist - tx.key_manager_insert_or_ignore(KeyBranch::ViewOnlyKey.as_str(), key_manager.key_index())?; + tx.key_manager_insert_or_ignore(KeyBranch::ViewOnlyKey.as_str(), next_index)?; } - tx.key_manager_insert_or_ignore(&key_manager.branch_seed, key_manager.key_index())?; + tx.key_manager_insert_or_ignore(&key_manager.branch_seed, next_index)?; tx.commit()?; - Ok(key) + Ok(key.into()) + } + + pub fn create_throwaway_nonce(&self) -> RistrettoSecretKey { + RistrettoSecretKey::random(&mut OsRng) } pub fn set_active_key>(&self, branch: B, index: u64) -> Result<(), KeyManagerApiError> { @@ -235,93 +329,49 @@ impl<'a, TStore: WalletStore> KeyManagerApi<'a, TStore> { Ok(()) } - pub fn get_active_key(&self, branch: KeyBranch) -> Result<(u64, DerivedKey), KeyManagerApiError> { - let index = self + pub fn get_active_key(&self, branch: KeyBranch) -> Result { + let key_index = self .store .with_read_tx(|tx| tx.key_manager_get_active_index(branch.as_str())) .optional()? .unwrap_or(0); - Ok((index, self.derive_key(branch, index)?)) - } - - pub fn get_key_or_active( - &self, - branch: KeyBranch, - maybe_index: Option, - ) -> Result<(u64, DerivedKey), KeyManagerApiError> { - match maybe_index { - Some(index) => Ok((index, self.derive_key(branch, index)?)), - None => self.get_active_key(branch), - } - } - - /// Brute force key search - /// WARNING: searching from 0 to u64::MAX will take in excess of 584942 years (assuming 1 microsecond per search) - /// for a key not to be found. Do not use this unless you are using a small range. - pub fn search_for_key_within_range( - &self, - branch: &str, - public_key: &RistrettoPublicKeyBytes, - start_index: u64, - end_index: u64, - ) -> Result<(u64, DerivedKey), KeyManagerApiError> { - let km = self.get_or_create_key_manager(branch)?; - for index in start_index..=end_index { - let key = km - .derive_key(index) - .map_err(key_manager::error::KeyManagerServiceError::from)?; - if RistrettoPublicKey::from_secret_key(&key.key).as_bytes() == public_key.as_bytes() { - return Ok((index, key)); - } - } - // For huge search ranges, it would take many years to get here! - Err(KeyManagerApiError::KeyNotFound { - key: *public_key, - branch: branch.to_string(), - }) + self.derive_key(branch, key_index) } - fn get_or_create_key_manager>(&self, branch: K) -> Result { - let branch_str = branch.as_ref(); - let mut tx = self.store.create_write_tx()?; - let index = match tx.key_manager_get_active_index(branch_str).optional()? { - Some(index) => { - tx.rollback()?; - index - }, + pub fn get_key_or_active(&self, branch: KeyBranch, maybe_key_id: Option) -> Result { + match maybe_key_id { + Some(id) => Ok(self.get_key(branch, id)?), None => { - tx.key_manager_insert_or_ignore(branch_str, 0)?; - tx.commit()?; - 0 + let key = self.get_active_key(branch)?; + Ok(key.into()) }, - }; - Ok(self.get_key_manager(branch_str, index)) + } } - fn get_key_manager>(&self, branch: B, index: u64) -> WalletKeyManager { - WalletKeyManager::from(self.cipher_seed.clone(), branch.as_ref().to_string(), index) + fn get_key_manager>(&self, branch: B) -> Result { + let cipher_seed = self.cipher_seed.cipher_seed().ok_or(KeyManagerApiError::ReadOnlyMode)?; + // We dont ever use the index in the key manager i.e. we dont ever call next_key on it, instead we always use + // derive_key + Ok(WalletKeyManager::from( + cipher_seed.clone(), + branch.as_ref().to_string(), + 0, + )) } } -impl Clone for KeyManagerApi<'_, TStore> { - fn clone(&self) -> Self { - *self - } -} - -impl Copy for KeyManagerApi<'_, TStore> {} - #[derive(Debug, thiserror::Error)] pub enum KeyManagerApiError { #[error("Store error: {0}")] StoreError(#[from] WalletStorageError), #[error("Key manager error: {0}")] KeyManagerError(#[from] key_manager::error::KeyManagerServiceError), - #[error("Key for public key {key}, branch {branch} not found")] - KeyNotFound { - key: RistrettoPublicKeyBytes, - branch: String, - }, + #[error("Key {key_id} not found")] + KeyNotFound { key_id: KeyId }, + #[error("Password manager error: {0}")] + PasswordManagerApiError(#[from] PasswordManagerApiError), + #[error("Key manager is in read only mode")] + ReadOnlyMode, } impl IsNotFoundError for KeyManagerApiError { diff --git a/crates/wallet/sdk/src/apis/mod.rs b/crates/wallet/sdk/src/apis/mod.rs index 9fbfa0d463..f898321fa9 100644 --- a/crates/wallet/sdk/src/apis/mod.rs +++ b/crates/wallet/sdk/src/apis/mod.rs @@ -8,6 +8,7 @@ pub mod confidential_transfer; pub mod config; pub mod key_manager; pub mod non_fungible_tokens; +pub mod password_manager; pub mod resources; pub mod stealth_crypto; pub mod stealth_outputs; diff --git a/crates/wallet/sdk/src/apis/password_manager.rs b/crates/wallet/sdk/src/apis/password_manager.rs new file mode 100644 index 0000000000..528af6ae9b --- /dev/null +++ b/crates/wallet/sdk/src/apis/password_manager.rs @@ -0,0 +1,144 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use digest::crypto_common::rand_core::{OsRng, RngCore}; +use passwords::PasswordGenerator; +use tari_crypto::tari_utilities::SafePassword; +use tari_ootle_common_types::{ + optional::{IsNotFoundError, Optional}, + Network, + NetworkParseError, +}; + +use crate::{ + apis::config::{ConfigApi, ConfigApiError, ConfigKey}, + storage::WalletStore, + WalletSdkConfig, +}; + +const KEYRING_ENTRIES_SERVICE: &str = "tari-ootle-wallet"; +const CIPHER_SEED_PASSWORD_KEYRING_ENTRY_NAME: &str = "cipher-seed-password"; + +#[derive(Clone)] +pub struct PasswordManagerApi<'a, TStore> { + override_keyring_password: Option<&'a SafePassword>, + config_api: ConfigApi<'a, TStore>, + network: Network, +} + +impl<'a, TStore: WalletStore> PasswordManagerApi<'a, TStore> { + pub(crate) fn new(config_api: ConfigApi<'a, TStore>, sdk_config: &'a WalletSdkConfig) -> Self { + Self { + config_api, + override_keyring_password: sdk_config.override_keyring_password.as_ref(), + network: sdk_config.network, + } + } + + pub fn get_cipher_seed_password(&self) -> Result { + if let Some(password) = self.override_keyring_password { + return Ok(password.clone()); + } + + let key = self.config_api.get::(ConfigKey::KeyringPasswordEntryKey)?; + let entry = self.get_cipher_seed_password_keyring_entry(&key)?; + // If get_password fails with NoEntry, it means that the password is not set in the keyring i.e. IsNotFoundError + // will return true which is what we want. + let password = entry.get_password()?; + Ok(SafePassword::from(password)) + } + + pub fn create_cipher_seed_password(&mut self) -> Result { + if let Some(password) = self.override_keyring_password { + // If we are overriding the keyring password, we don't need to set it in the keyring. + // This is because the password is already set in the config. + return Ok(password.clone()); + } + + let key = match self + .config_api + .get::(ConfigKey::KeyringPasswordEntryKey) + .optional()? + { + Some(key) => key, + None => { + // If the key is not set, we generate a new key and set it in the config. + // The nonce is used to differentiate between different password entries in the keyring when running + // multiple instances of the wallet on the same network. This nonce is generated once per wallet + // database. + let nonce = generate_password_entry_key_nonce(); + let key = format!("{}-{}-{}", CIPHER_SEED_PASSWORD_KEYRING_ENTRY_NAME, self.network, nonce); + self.config_api.set(ConfigKey::KeyringPasswordEntryKey, &key)?; + key + }, + }; + + let str_password = generate_password()?; + let entry = self.get_cipher_seed_password_keyring_entry(&key)?; + entry.set_password(&str_password)?; + + Ok(SafePassword::from(str_password)) + } + + fn get_cipher_seed_password_keyring_entry(&self, key: &str) -> Result { + let result = keyring::Entry::new(KEYRING_ENTRIES_SERVICE, key); + + match result { + Ok(entry) => Ok(entry), + Err(keyring::Error::NoEntry) => { + // NoEntry maps to various errors in the keyring codebase, including AccessDenied, keyExpired etc. + // Entry::new says that it will only return an error if the service/user are invalid but there may be + // more errors possible e.g. AccessDenied. In any case we provide a better error than NoEntry for this + // case. We dont want IsNotFoundError to be true for this case. + Err(PasswordManagerApiError::FailedToAccessKeyRing) + }, + Err(err) => Err(err.into()), + } + } +} + +fn generate_password_entry_key_nonce() -> u64 { + OsRng.next_u64() +} + +/// Generate a new random password. +fn generate_password() -> Result { + let pg = PasswordGenerator { + length: 256, + numbers: true, + lowercase_letters: true, + uppercase_letters: true, + symbols: false, + spaces: false, + exclude_similar_characters: false, + strict: true, + }; + let generated_password = pg + .generate_one() + .map_err(|error| PasswordManagerApiError::PasswordGeneration(error.to_string()))?; + + Ok(generated_password) +} + +#[derive(Debug, thiserror::Error)] +pub enum PasswordManagerApiError { + #[error("Config API error: {0}")] + ConfigApiError(#[from] ConfigApiError), + #[error("OS Keyring error: {0}")] + KeyRing(#[from] keyring::Error), + #[error("Failed to generate password for cipher seed: {0}")] + PasswordGeneration(String), + #[error( + "OS keyring not supported on this device. You may have to specify an encryption password by using the \ + `--password` cli option." + )] + FailedToAccessKeyRing, + #[error(transparent)] + NetworkParseError(#[from] NetworkParseError), +} + +impl IsNotFoundError for PasswordManagerApiError { + fn is_not_found_error(&self) -> bool { + matches!(self, Self::KeyRing(e) if matches!(e, keyring::Error::NoEntry)) + } +} diff --git a/crates/wallet/sdk/src/apis/resources.rs b/crates/wallet/sdk/src/apis/resources.rs index 472d21a1fb..d7cbe8d16a 100644 --- a/crates/wallet/sdk/src/apis/resources.rs +++ b/crates/wallet/sdk/src/apis/resources.rs @@ -4,8 +4,8 @@ use std::collections::HashMap; use tari_engine_types::resource::Resource; -use tari_ootle_common_types::optional::IsNotFoundError; -use tari_template_lib::{models::ResourceAddress, prelude::ResourceType}; +use tari_ootle_common_types::optional::{IsNotFoundError, Optional}; +use tari_template_lib::{models::ResourceAddress, types::ResourceType}; use thiserror::Error; use crate::storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}; @@ -31,6 +31,12 @@ where TStore: WalletStore Ok(resource.into()) } + pub fn exists(&self, address: &ResourceAddress) -> Result { + // TODO(perf): consider adding an exists method + let exists = self.store.with_read_tx(|tx| tx.resources_get(address)).optional()?; + Ok(exists.is_some()) + } + pub fn get_addresses_by_type( &self, resource_type: ResourceType, diff --git a/crates/wallet/sdk/src/apis/stealth_outputs.rs b/crates/wallet/sdk/src/apis/stealth_outputs.rs index dfb871c5d1..ef7f9857d2 100644 --- a/crates/wallet/sdk/src/apis/stealth_outputs.rs +++ b/crates/wallet/sdk/src/apis/stealth_outputs.rs @@ -34,7 +34,7 @@ use crate::{ stealth_crypto::{StealthCryptoApi, StealthCryptoApiError}, stealth_transfer::InputToSpend, }, - models::{Account, KeyPair, OutputStatus, StealthBalance, StealthOutputModel, WalletLockId}, + models::{Account, AccountAndViewKeys, OutputStatus, StealthBalance, StealthOutputModel, WalletLockId}, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, }; @@ -178,7 +178,7 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { pub fn release_locked_outputs(&self, lock_id: WalletLockId) -> Result<(), StealthOutputsApiError> { self.store.with_write_tx(|tx| { - tx.outputs_release_by_lock_id(lock_id)?; + tx.confidential_outputs_release_by_lock_id(lock_id)?; tx.locks_delete(lock_id)?; Ok(()) }) @@ -199,9 +199,20 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { ) -> Result, StealthOutputsApiError> { let network = self.config_api.get_network()?; // Derive owner secret - the sender does not know the owner secret - let owner_key_part = self.key_manager_api.derive_account_key(owner_account.key_index())?; + let owner_key_id = owner_account + .owner_key_id() + .ok_or_else(|| StealthOutputsApiError::InvalidParameter { + param: "owner_key_id", + reason: format!( + "Account {} does not have an owner key. Cannot spend from this account", + owner_account + ), + })?; + let owner_key_part = self.key_manager_api.get_account_owner_key(owner_key_id)?; // Derive the view-only secret, of which the public key is used by senders to encrypt the value and mask. - let view_only = self.key_manager_api.derive_view_only_key(owner_account.key_index())?; + let view_only = self + .key_manager_api + .get_view_only_key(owner_account.view_only_key_id())?; let mut inputs_with_masks = Vec::with_capacity(outputs.len()); for output in outputs { // Derive the decryption key from the DHKE(sender's public nonce, encryption secret key); @@ -215,13 +226,13 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { let mask_and_value = self.crypto_api.decrypt_value_and_mask( &output.encrypted_data, &output.commitment, - &view_only.key, + &view_only.secret, &nonce, )?; let stealth_secret = self .crypto_api - .derive_stealth_owner_secret(network, &owner_key_part.key, &nonce); + .derive_stealth_owner_secret(network, &owner_key_part.secret, &nonce); inputs_with_masks.push(InputToSpend { statement: UnblindedStealthInputStatement { @@ -336,16 +347,29 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { Ok(outputs) } + #[allow(clippy::too_many_lines)] pub fn verify_and_update_outputs<'i, I: IntoIterator>( &self, outputs: I, ) -> Result<(), StealthOutputsApiError> { let all_used_view_only_keys = self .key_manager_api - .get_all_keys(KeyBranch::ViewOnlyKey)? + .get_all_derived_keys(KeyBranch::ViewOnlyKey)? .into_iter() - .map(|k| k.key_pair) - .collect::>(); + .map(|view_key| { + let account_key = self.key_manager_api.derive_account_key( + view_key + .key_id + .derived_index() + .expect("get_all_derived_keys returns only derived keys"), + )?; + Ok::<_, KeyManagerApiError>(AccountAndViewKeys { + account_public_key: account_key.to_public_key().to_byte_type(), + account_key: Some(account_key.into()), + view_only_key: view_key.into(), + }) + }) + .collect::, _>>()?; let network = self.config_api.get_network()?; let mut found_utxos_count = 0usize; @@ -449,7 +473,7 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { #[allow(clippy::too_many_lines)] pub fn validate_utxo( &self, - all_used_account_view_only_keys: &[KeyPair], + all_used_account_view_only_keys: &[AccountAndViewKeys], network: Network, resource_address: ResourceAddress, commitment: PedersenCommitmentBytes, @@ -484,48 +508,53 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { output_stealth_public_nonce, ); - for view_only_key in all_used_account_view_only_keys { + for keys in all_used_account_view_only_keys { trace!( target: LOG_TARGET, - "Attempting to unblind output with view key index {} {}", - view_only_key.key_index(), - view_only_key.public_key + "Attempting to unblind output with view key {}", + keys.view_only_key.key_id, ); let unblinded_result = self.crypto_api.decrypt_value_and_mask( &output.output.encrypted_data, &commitment, - &view_only_key.secret_key.key, + &keys.view_only_key.secret, &output_stealth_public_nonce, ); - let (value, owner_key, status) = match unblinded_result { + let (value, status) = match unblinded_result { Ok(mask_and_value) => { - let owner_key = self - .key_manager_api - .derive_account_key_pair(view_only_key.secret_key.key_index)?; - let stealth_secret = self.crypto_api.derive_stealth_owner_secret( - network, - &owner_key.secret_key.key, - &output_stealth_public_nonce, - ); - let stealth_address = RistrettoPublicKey::from_secret_key(&stealth_secret); - if output.owner_public_key == stealth_address.to_byte_type() { - (mask_and_value.value, owner_key, OutputStatus::Unspent) + if let Some(ref owner_key) = keys.account_key { + let stealth_secret = self.crypto_api.derive_stealth_owner_secret( + network, + &owner_key.secret, + &output_stealth_public_nonce, + ); + let stealth_address = RistrettoPublicKey::from_secret_key(&stealth_secret); + if output.owner_public_key == stealth_address.to_byte_type() { + (mask_and_value.value, OutputStatus::Unspent) + } else { + warn!( + target: LOG_TARGET, + "⚠️ Output owner public key does not match the expected stealth address. (expected: {}, actual: {}). Utxo cannot be spent by this wallet and will be stored as invalid.", + stealth_address, + output.owner_public_key + ); + (mask_and_value.value, OutputStatus::Invalid) + } } else { - warn!( + info!( target: LOG_TARGET, - "Output owner public key does not match the expected stealth address. (expected: {}, actual: {}). Utxo cannot be spent by this wallet and will be stored as invalid.", - stealth_address, - output.owner_public_key + "Output can only be viewed, not spent, as there is no owner key for view key {}", + keys.view_only_key.key_id, ); - (mask_and_value.value, owner_key, OutputStatus::Invalid) + (mask_and_value.value, OutputStatus::Unspent) } }, Err(e) => { debug!( target: LOG_TARGET, "Failed to unblind output for key {}. (commitment: {}, error: {})", - view_only_key.secret_key.key_index, + keys.view_only_key.key_id, commitment, e ); @@ -533,10 +562,8 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { }, }; - let owner_account = derive_component_address_from_public_key( - &ACCOUNT_TEMPLATE_ADDRESS, - &owner_key.public_key.to_byte_type(), - ); + let owner_account = + derive_component_address_from_public_key(&ACCOUNT_TEMPLATE_ADDRESS, &keys.account_public_key); info!( target: LOG_TARGET, "🟢 Unblinded output for account {}. (commitment: {}, value: {})", @@ -553,7 +580,8 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { commitment, value, sender_public_nonce: output_stealth_public_nonce.to_byte_type(), - encryption_secret_key_index: view_only_key.key_index(), + view_only_key_id: keys.view_only_key.key_id, + owner_key_id: keys.account_key.as_ref().map(|k| k.key_id), encrypted_data: output.output.encrypted_data.clone(), tag_byte: output.tag, status, diff --git a/crates/wallet/sdk/src/apis/stealth_transfer.rs b/crates/wallet/sdk/src/apis/stealth_transfer.rs index 792474b58c..80a5640973 100644 --- a/crates/wallet/sdk/src/apis/stealth_transfer.rs +++ b/crates/wallet/sdk/src/apis/stealth_transfer.rs @@ -539,7 +539,7 @@ where }; // If we're spending from the owner account, add the inputs - if inputs_to_spend.revealed.is_positive() { + if inputs_to_spend.revealed.is_positive() || fee_inputs_to_spend.revealed.is_positive() { substate_inputs.push(SubstateRequirement::unversioned(*owner_account.component_address())); // Add the vaults for XTR (fees) and the spending resource if different @@ -635,19 +635,27 @@ where ) -> Result { let revealed_input_amount = transfer_statement.inputs_statement.revealed_amount; let revealed_output_amount = transfer_statement.outputs_statement.revealed_output_amount; + let owner_key_id = owner_account + .owner_key_id() + .ok_or_else(|| StealthTransferApiError::InvalidParameter { + param: "owner_account", + reason: "Owner account has no owner key".to_string(), + })?; - let signer_secret = if revealed_input_amount.is_positive() { - self.key_manager_api.derive_account_key(owner_account.key_index())? + let signer_key = if revealed_input_amount.is_positive() || + fee_transfer_statement.inputs_statement.revealed_amount.is_positive() + { + self.key_manager_api.get_account_owner_key(owner_key_id)? } else { // Since we don't require account auth, use a throwaway nonce to sign the transaction - self.key_manager_api.next_key(KeyBranch::Nonce)? + self.key_manager_api.next_key(KeyBranch::Nonce)?.into() }; let transaction = Transaction::builder() .for_network(params.destination_address.network().as_byte()) .with_dry_run(params.is_dry_run) .with_fee_instructions_builder(|builder| { - if revealed_input_amount.is_positive() { + if fee_transfer_statement.inputs_statement.revealed_amount.is_positive() { builder .call_method(*owner_account.component_address(), "withdraw", args![ XTR, @@ -695,7 +703,9 @@ where }) }) .with_inputs(inputs) - .build_and_seal(&signer_secret.key); + // TODO: remove the need to add this input + .add_input(XTR) + .build_and_seal(&signer_key.secret); Ok(transaction) } @@ -765,7 +775,8 @@ where .to_byte_type(), value: output_value, sender_public_nonce: output.statement.sender_public_nonce.to_byte_type(), - encryption_secret_key_index: account.key_index(), + view_only_key_id: account.view_only_key_id(), + owner_key_id: account.owner_key_id(), encrypted_data: output.statement.encrypted_data.clone(), status: OutputStatus::LockedUnconfirmed, tag_byte: output.tag, diff --git a/crates/wallet/sdk/src/apis/transaction.rs b/crates/wallet/sdk/src/apis/transaction.rs index 99dee3b1af..b4fb0e60de 100644 --- a/crates/wallet/sdk/src/apis/transaction.rs +++ b/crates/wallet/sdk/src/apis/transaction.rs @@ -267,7 +267,7 @@ where let lock_ids = tx.locks_get_by_transaction_id(transaction_id)?; info!(target: LOG_TARGET, "Finalizing locked outputs for transaction {}: {:?}", transaction_id, lock_ids); for lock_id in lock_ids { - tx.outputs_finalize_by_lock_id(lock_id)?; + tx.confidential_outputs_finalize_by_lock_id(lock_id)?; tx.stealth_outputs_finalize_by_lock_id(lock_id)?; tx.vaults_finalized_locked_revealed_funds(lock_id).optional()?; tx.locks_delete(lock_id)?; @@ -298,7 +298,7 @@ where debug!(target: LOG_TARGET, "Releasing {} locks (and associated outputs) for transaction {} that was not committed", lock_ids.len(), transaction_id); for lock_id in lock_ids { // Lock could be for confidential outputs or stealth outputs - tx.outputs_release_by_lock_id(lock_id)?; + tx.confidential_outputs_release_by_lock_id(lock_id)?; tx.stealth_outputs_release_by_lock_id(lock_id)?; // If the lock locks a vault, we need to release the revealed funds tx.vaults_release_lock_revealed_funds(lock_id).optional()?; diff --git a/crates/wallet/sdk/src/cipher_seed.rs b/crates/wallet/sdk/src/cipher_seed.rs new file mode 100644 index 0000000000..8896fc16f5 --- /dev/null +++ b/crates/wallet/sdk/src/cipher_seed.rs @@ -0,0 +1,41 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use std::sync::Arc; + +use tari_common_types::seeds::{cipher_seed::CipherSeed, seed_words::SeedWords}; + +#[derive(Debug, Copy, Clone, Default)] +pub enum CipherSeedRestore<'a> { + #[default] + CreateNewIfRequired, + FromSeedWords(&'a SeedWords), +} + +impl<'a> CipherSeedRestore<'a> { + pub fn is_create_new(&self) -> bool { + matches!(self, CipherSeedRestore::CreateNewIfRequired) + } +} + +#[derive(Debug, Clone, Default)] +pub enum WalletCipherSeed { + #[default] + None, + CipherSeed(Arc), +} + +impl WalletCipherSeed { + pub fn cipher_seed(&self) -> Option<&CipherSeed> { + match self { + Self::CipherSeed(seed) => Some(seed), + Self::None => None, + } + } +} + +impl From for WalletCipherSeed { + fn from(seed: CipherSeed) -> Self { + Self::CipherSeed(Arc::new(seed)) + } +} diff --git a/crates/wallet/sdk/src/lib.rs b/crates/wallet/sdk/src/lib.rs index ad6ff48d05..dfd2991add 100644 --- a/crates/wallet/sdk/src/lib.rs +++ b/crates/wallet/sdk/src/lib.rs @@ -10,6 +10,7 @@ mod sdk; pub use sdk::{WalletSdk, WalletSdkConfig}; pub use tari_common_types::seeds::cipher_seed::CipherSeed; +pub mod cipher_seed; pub mod network; pub type WalletSecretKey = tari_transaction_components::key_manager::tari_key_manager::DerivedKey; diff --git a/crates/wallet/sdk/src/models/account.rs b/crates/wallet/sdk/src/models/account.rs index cf3dab9682..7ef12a9c35 100644 --- a/crates/wallet/sdk/src/models/account.rs +++ b/crates/wallet/sdk/src/models/account.rs @@ -7,13 +7,16 @@ use tari_bor::{Deserialize, Serialize}; use tari_ootle_address::OotleAddress; use tari_template_lib::{models::ComponentAddress, prelude::RistrettoPublicKeyBytes}; +use crate::models::KeyId; + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] pub struct Account { pub name: Option, pub component_address: ComponentAddress, - #[cfg_attr(feature = "ts", ts(type = "number"))] - pub key_index: u64, + pub view_only_key_id: KeyId, + pub owner_key_id: Option, + pub owner_public_key: RistrettoPublicKeyBytes, pub is_confirmed_on_chain: bool, pub is_default: bool, } @@ -23,8 +26,16 @@ impl Account { &self.component_address } - pub fn key_index(&self) -> u64 { - self.key_index + pub fn view_only_key_id(&self) -> KeyId { + self.view_only_key_id + } + + pub fn owner_key_id(&self) -> Option { + self.owner_key_id + } + + pub fn owner_public_key(&self) -> &RistrettoPublicKeyBytes { + &self.owner_public_key } pub fn name(&self) -> Option<&String> { @@ -69,8 +80,16 @@ impl AccountWithAddress { &self.address } - pub fn key_index(&self) -> u64 { - self.account.key_index + pub fn name(&self) -> Option<&String> { + self.account.name.as_ref() + } + + pub fn view_only_key_id(&self) -> KeyId { + self.account.view_only_key_id + } + + pub fn owner_key_id(&self) -> Option { + self.account.owner_key_id } pub fn owner_public_key(&self) -> &RistrettoPublicKeyBytes { diff --git a/crates/wallet/sdk/src/models/confidential_output.rs b/crates/wallet/sdk/src/models/confidential_output.rs index e7d87c5bf4..7c2f1eca71 100644 --- a/crates/wallet/sdk/src/models/confidential_output.rs +++ b/crates/wallet/sdk/src/models/confidential_output.rs @@ -9,7 +9,7 @@ use tari_template_lib::{ types::Amount, }; -use crate::models::WalletLockId; +use crate::models::{KeyId, WalletLockId}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConfidentialOutputModel { @@ -18,7 +18,8 @@ pub struct ConfidentialOutputModel { pub commitment: PedersenCommitmentBytes, pub value: Amount, pub sender_public_nonce: Option, - pub encryption_secret_key_index: u64, + pub view_only_key_id: KeyId, + pub owner_key_id: Option, pub encrypted_data: EncryptedData, pub public_asset_tag: Option, pub status: OutputStatus, diff --git a/crates/wallet/sdk/src/models/key.rs b/crates/wallet/sdk/src/models/key.rs index aa927cf079..e3dcad4728 100644 --- a/crates/wallet/sdk/src/models/key.rs +++ b/crates/wallet/sdk/src/models/key.rs @@ -1,44 +1,154 @@ // Copyright 2025 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use tari_crypto::ristretto::{RistrettoPublicKey, RistrettoSecretKey}; -use tari_ootle_address::RistrettoOotleAddress; -use tari_transaction_components::key_manager::tari_key_manager::DerivedKey; +use std::{fmt::Display, str::FromStr}; -use crate::apis::key_manager::KeyBranch; +use tari_crypto::{ + keys::PublicKey, + ristretto::{RistrettoPublicKey, RistrettoSecretKey}, +}; +use tari_ootle_address::RistrettoOotleAddress; +use tari_template_lib::prelude::RistrettoPublicKeyBytes; #[derive(Clone)] -pub struct WalletKey { - pub branch: KeyBranch, - pub key_pair: KeyPair, - pub is_active: bool, +pub struct WalletKeyRecord { + pub(crate) key_id: KeyId, + pub(crate) public_key: RistrettoPublicKey, + pub(crate) secret_key: RistrettoSecretKey, + pub(crate) is_active: bool, } -impl WalletKey { - pub fn key_index(&self) -> u64 { - self.key_pair.secret_key.key_index +impl WalletKeyRecord { + pub fn key_id(&self) -> KeyId { + self.key_id + } + + pub fn is_active(&self) -> bool { + self.is_active } pub fn public_key(&self) -> &RistrettoPublicKey { - &self.key_pair.public_key + &self.public_key } } #[derive(Clone, serde::Serialize, serde::Deserialize)] -pub struct DerivedAddress { +pub struct WalletOotleAddressWithKeyIds { pub address: RistrettoOotleAddress, - pub key_index: u64, + pub view_only_key_id: KeyId, + pub owner_key_id: KeyId, } #[derive(Clone)] -pub struct KeyPair { +pub struct ImportedWalletKey { + pub key: RistrettoSecretKey, + pub import_id: ImportedKeyId, + pub key_type: KeyType, +} + +impl ImportedWalletKey { + pub fn to_public_key(&self) -> RistrettoPublicKey { + RistrettoPublicKey::from_secret_key(&self.key) + } + + pub fn as_key_id(&self) -> KeyId { + KeyId::imported(self.import_id) + } +} + +#[derive(Clone)] +pub struct DerivedWalletKey { + pub key: RistrettoSecretKey, + pub key_index: DerivedKeyIndex, +} + +impl DerivedWalletKey { + pub fn to_public_key(&self) -> RistrettoPublicKey { + RistrettoPublicKey::from_secret_key(&self.key) + } + + pub fn as_key_id(&self) -> KeyId { + KeyId::derived(self.key_index) + } +} + +impl From for DerivedWalletKey { + fn from(key: tari_transaction_components::key_manager::tari_key_manager::DerivedKey) -> Self { + Self { + key: key.key, + key_index: key.key_index, + } + } +} + +#[derive(Clone)] +pub struct Key { + pub secret: RistrettoSecretKey, + pub key_id: KeyId, +} + +impl Key { + pub fn secret(&self) -> &RistrettoSecretKey { + &self.secret + } + + pub fn to_public_key(&self) -> RistrettoPublicKey { + RistrettoPublicKey::from_secret_key(&self.secret) + } +} + +impl From for Key { + fn from(pair: DerivedKeyPair) -> Self { + Self { + key_id: pair.derived_key.as_key_id(), + secret: pair.derived_key.key, + } + } +} + +impl From for Key { + fn from(derived: DerivedWalletKey) -> Self { + Self { + key_id: derived.as_key_id(), + secret: derived.key, + } + } +} + +impl From for Key { + fn from(imported: ImportedWalletKey) -> Self { + Self { + key_id: imported.as_key_id(), + secret: imported.key, + } + } +} + +impl From for Key { + fn from(record: WalletKeyRecord) -> Self { + Self { + secret: record.secret_key, + key_id: record.key_id, + } + } +} + +#[derive(Clone)] +pub struct AccountAndViewKeys { + pub account_public_key: RistrettoPublicKeyBytes, + pub account_key: Option, + pub view_only_key: Key, +} + +#[derive(Clone)] +pub struct DerivedKeyPair { pub public_key: RistrettoPublicKey, - pub secret_key: DerivedKey, + pub derived_key: DerivedWalletKey, } -impl KeyPair { - pub fn key_index(&self) -> u64 { - self.secret_key.key_index +impl DerivedKeyPair { + pub fn key_index(&self) -> DerivedKeyIndex { + self.derived_key.key_index } pub fn public_key(&self) -> &RistrettoPublicKey { @@ -46,6 +156,110 @@ impl KeyPair { } pub fn secret_key(&self) -> &RistrettoSecretKey { - &self.secret_key.key + &self.derived_key.key + } +} + +pub type DerivedKeyIndex = u64; +pub type ImportedKeyId = u64; + +#[derive(Debug, Clone, Copy)] +pub enum KeyType { + /// View only key + ViewOnly, + /// Owner key, allows spending and write access to components + Owner, + /// General purpose key, can be used for any purpose + GeneralPurpose, +} + +impl Display for KeyType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ViewOnly => write!(f, "ViewOnly"), + Self::Owner => write!(f, "Owner"), + Self::GeneralPurpose => write!(f, "GeneralPurpose"), + } + } +} + +impl FromStr for KeyType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "ViewOnly" => Ok(Self::ViewOnly), + "Owner" => Ok(Self::Owner), + "GeneralPurpose" => Ok(Self::GeneralPurpose), + _ => Err(anyhow::anyhow!("Invalid key type: {}", s)), + } + } +} + +pub enum KeyIdOrPublicKey { + KeyId(KeyId), + PublicKey(RistrettoPublicKeyBytes), +} + +impl From for KeyIdOrPublicKey { + fn from(key_id: KeyId) -> Self { + Self::KeyId(key_id) + } +} + +impl From for KeyIdOrPublicKey { + fn from(public_key: RistrettoPublicKeyBytes) -> Self { + Self::PublicKey(public_key) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] +pub enum KeyId { + /// Derived from the seed key + Derived { index: DerivedKeyIndex }, + /// Imported key + Imported { local_key_id: ImportedKeyId }, +} + +impl KeyId { + pub fn derived(index: DerivedKeyIndex) -> Self { + Self::Derived { index } + } + + pub fn imported(local_key_id: ImportedKeyId) -> Self { + Self::Imported { local_key_id } + } + + pub fn derived_index(&self) -> Option { + match self { + Self::Derived { index } => Some(*index), + Self::Imported { .. } => None, + } + } + + pub fn imported_view_key_id(&self) -> Option { + match self { + Self::Imported { local_key_id } => Some(*local_key_id), + Self::Derived { .. } => None, + } + } + + pub fn imported_owner_key_id(&self) -> Option { + match self { + Self::Imported { local_key_id, .. } => Some(*local_key_id), + Self::Derived { .. } => None, + } + } +} + +impl Display for KeyId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Derived { index } => write!(f, "Derived({index})"), + Self::Imported { + local_key_id: local_import_id, + } => write!(f, "Imported({local_import_id})"), + } } } diff --git a/crates/wallet/sdk/src/models/stealth_output.rs b/crates/wallet/sdk/src/models/stealth_output.rs index d5e285158e..904339001e 100644 --- a/crates/wallet/sdk/src/models/stealth_output.rs +++ b/crates/wallet/sdk/src/models/stealth_output.rs @@ -7,7 +7,7 @@ use tari_template_lib::{ types::{crypto::UtxoTag, Amount}, }; -use crate::models::{OutputStatus, WalletLockId}; +use crate::models::{KeyId, OutputStatus, WalletLockId}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct StealthOutputModel { @@ -17,7 +17,9 @@ pub struct StealthOutputModel { pub value: Amount, pub sender_public_nonce: RistrettoPublicKeyBytes, /// Note: this field is more for debugging. We use the account key index for all outputs belonging to an account - pub encryption_secret_key_index: u64, + pub view_only_key_id: KeyId, + /// None means this output cannot be spent, it's view-only + pub owner_key_id: Option, pub encrypted_data: EncryptedData, pub tag_byte: UtxoTag, pub status: OutputStatus, diff --git a/crates/wallet/sdk/src/models/vault.rs b/crates/wallet/sdk/src/models/vault.rs index e6789e876e..2a3e9fc547 100644 --- a/crates/wallet/sdk/src/models/vault.rs +++ b/crates/wallet/sdk/src/models/vault.rs @@ -3,8 +3,7 @@ use tari_template_lib::{ models::{ComponentAddress, ResourceAddress, VaultId}, - resource::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/crates/wallet/sdk/src/sdk.rs b/crates/wallet/sdk/src/sdk.rs index 69ff5ac31d..7e4b814bac 100644 --- a/crates/wallet/sdk/src/sdk.rs +++ b/crates/wallet/sdk/src/sdk.rs @@ -1,11 +1,7 @@ // Copyright 2023 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use std::sync::Arc; - -use digest::crypto_common::rand_core::{OsRng, RngCore}; use log::{info, warn}; -use passwords::PasswordGenerator; use tari_common_types::seeds::{ cipher_seed::CipherSeed, error::CipherError, @@ -29,6 +25,7 @@ use crate::{ config::{ConfigApi, ConfigApiError, ConfigKey}, key_manager::{KeyManagerApi, KeyManagerApiError}, non_fungible_tokens::NonFungibleTokensApi, + password_manager::{PasswordManagerApi, PasswordManagerApiError}, resources::ResourcesApi, stealth_crypto::StealthCryptoApi, stealth_outputs::StealthOutputsApi, @@ -38,13 +35,11 @@ use crate::{ transaction::TransactionApi, viewable_balance::ViewableBalanceApi, }, + cipher_seed::{CipherSeedRestore, WalletCipherSeed}, network::{StatusResponseError, WalletNetworkInterface}, storage::{WalletStorageError, WalletStore}, }; -const KEYRING_ENTRIES_SERVICE: &str = "tari-ootle-wallet"; -const CIPHER_SEED_PASSWORD_KEYRING_ENTRY_NAME: &str = "cipher-seed-password"; - const LOG_TARGET: &str = "wallet::sdk::api"; #[derive(Debug, Clone)] @@ -59,7 +54,7 @@ pub struct WalletSdk { store: TStore, network_interface: TNetworkInterface, config: WalletSdkConfig, - loaded_cipher_seed: Option>, + loaded_cipher_seed: WalletCipherSeed, } impl WalletSdk @@ -76,24 +71,24 @@ where // initialize network let config_api = ConfigApi::new(&store); if !config_api.exists(ConfigKey::Network)? { - config_api.set(ConfigKey::Network, config.network.as_key_str(), false)?; + config_api.set(ConfigKey::Network, config.network.as_key_str())?; } Ok(Self { store, network_interface: indexer, config, - loaded_cipher_seed: None, + loaded_cipher_seed: WalletCipherSeed::None, }) } /// Initializes the cipher seed for the wallet. Either creating a new cipher seed or recovering it from the provided /// seed words if provided and necessary. Returns true if the cipher seed was recovered from the seed words, /// otherwise false. - pub fn initialize_cipher_seed(&mut self, seed_words: Option<&SeedWords>) -> Result { + pub fn initialize_cipher_seed(&mut self, restore: CipherSeedRestore<'_>) -> Result { match self.load_cipher_seed()? { Some(_) => { - if seed_words.is_some() { + if !restore.is_create_new() { warn!( target: LOG_TARGET, "⚠️ Wallet already initialized. Ignoring seed words provided for recovery.", @@ -105,17 +100,18 @@ where details: "Cipher seed already initialized but recovery_needed not set.".to_string(), }) }, - None => { - if let Some(seed_words) = seed_words { - self.restore_cipher_seed(seed_words)?; - info!(target: LOG_TARGET, "🔑 Successfully restored wallet seed key!"); - self.config_api().set(ConfigKey::RecoveryNeeded, &true, false)?; - Ok(true) - } else { + None => match restore { + CipherSeedRestore::CreateNewIfRequired => { self.create_cipher_seed()?; - self.config_api().set(ConfigKey::RecoveryNeeded, &false, false)?; + self.config_api().set(ConfigKey::RecoveryNeeded, &false)?; Ok(false) - } + }, + CipherSeedRestore::FromSeedWords(seed_words) => { + self.restore_cipher_seed_from_seed_words(seed_words)?; + info!(target: LOG_TARGET, "🔑 Successfully restored wallet seed key!"); + self.config_api().set(ConfigKey::RecoveryNeeded, &true)?; + Ok(true) + }, }, } } @@ -150,12 +146,15 @@ where KeyManagerApi::new( network, &self.store, - self.loaded_cipher_seed - .as_ref() - .expect("key_manager_api: cipher seed not initialized. initialize_cipher_seed must be called first"), + &self.loaded_cipher_seed, + self.password_manager_api(), ) } + pub(crate) fn password_manager_api(&self) -> PasswordManagerApi<'_, TStore> { + PasswordManagerApi::new(self.config_api(), &self.config) + } + pub fn transaction_api(&self) -> TransactionApi<'_, TStore, TNetworkInterface> { TransactionApi::new(&self.store, &self.network_interface) } @@ -165,7 +164,12 @@ where } pub fn accounts_api(&self) -> AccountsApi<'_, TStore, TNetworkInterface> { - AccountsApi::new(&self.store, self.substate_api(), self.key_manager_api()) + AccountsApi::new( + self.config.network, + &self.store, + self.substate_api(), + self.key_manager_api(), + ) } pub fn resources_api(&self) -> ResourcesApi<'_, TStore> { @@ -228,40 +232,43 @@ where } /// Tries to get encrypted cipher seed from DB and decrypts it using OS keyring if possible. - fn load_cipher_seed(&mut self) -> Result>, WalletSdkError> { - if let Some(ref cipher_seed) = self.loaded_cipher_seed { - return Ok(Some(cipher_seed.clone())); + fn load_cipher_seed(&mut self) -> Result, WalletSdkError> { + // Workaround for borrow checker limitation as described in https://blog.polybdenum.com/2024/12/21/four-limitations-of-rust-s-borrow-checker.html + if self.loaded_cipher_seed.cipher_seed().is_some() { + return Ok(Some(self.loaded_cipher_seed.cipher_seed().expect("checked above"))); } - let Some(cipher_seed_encrypted) = self.config_api().get::>(ConfigKey::CipherSeed).optional()? else { + let Some(cipher_seed_encrypted) = self + .config_api() + .get::>>(ConfigKey::CipherSeed) + .optional()? + else { // Cipher seed not found in DB. This is expected if the wallet has not been initialized yet. return Ok(None); }; - let password = self.get_cipher_seed_password()?; + let password = self.password_manager_api().get_cipher_seed_password()?; let cipher_seed = CipherSeed::from_enciphered_bytes(&cipher_seed_encrypted, Some(password))?; - self.loaded_cipher_seed = Some(Arc::new(cipher_seed)); - Ok(self.loaded_cipher_seed.clone()) + self.loaded_cipher_seed = cipher_seed.into(); + Ok(self.loaded_cipher_seed.cipher_seed()) } fn create_cipher_seed(&mut self) -> Result<(), WalletSdkError> { + let password = self.password_manager_api().create_cipher_seed_password()?; let cipher_seed = CipherSeed::new(); - let password = self.create_cipher_seed_password()?; let encrypted_cipher_seed = cipher_seed.encipher(Some(password))?; - self.config_api() - .set(ConfigKey::CipherSeed, &encrypted_cipher_seed, true)?; - self.loaded_cipher_seed = Some(Arc::new(cipher_seed)); + self.config_api().set(ConfigKey::CipherSeed, &encrypted_cipher_seed)?; + self.loaded_cipher_seed = cipher_seed.into(); Ok(()) } /// Restores cipher seed from seed words, encrypts with a new random password (and saves to OS keychain) /// and replaces current cipher seed in the DB (to let every component use the new seed). - fn restore_cipher_seed(&mut self, seed_words: &SeedWords) -> Result<(), WalletSdkError> { + fn restore_cipher_seed_from_seed_words(&mut self, seed_words: &SeedWords) -> Result<(), WalletSdkError> { + let password = self.password_manager_api().create_cipher_seed_password()?; let cipher_seed = CipherSeed::from_mnemonic(seed_words, None)?; - let password = self.create_cipher_seed_password()?; let encrypted_cipher_seed = cipher_seed.encipher(Some(password))?; - self.config_api() - .set(ConfigKey::CipherSeed, &encrypted_cipher_seed, true)?; - self.loaded_cipher_seed = Some(Arc::new(cipher_seed)); + self.config_api().set(ConfigKey::CipherSeed, &encrypted_cipher_seed)?; + self.loaded_cipher_seed = cipher_seed.into(); Ok(()) } @@ -275,69 +282,6 @@ where .to_mnemonic(MnemonicLanguage::English, None)?; Ok(seed_words) } - - fn get_cipher_seed_password(&self) -> Result { - if let Some(ref password) = self.config.override_keyring_password { - return Ok(password.clone()); - } - - let key = self.config_api().get::(ConfigKey::KeyringPasswordEntryKey)?; - let entry = self.get_cipher_seed_password_keyring_entry(&key)?; - // If get_password fails with NoEntry, it means that the password is not set in the keyring i.e. IsNotFoundError - // will return true which is what we want. - let password = entry.get_password()?; - Ok(SafePassword::from(password)) - } - - fn create_cipher_seed_password(&mut self) -> Result { - if let Some(ref password) = self.config.override_keyring_password { - // If we are overriding the keyring password, we don't need to set it in the keyring. - // This is because the password is already set in the config. - return Ok(password.clone()); - } - - let key = match self - .config_api() - .get::(ConfigKey::KeyringPasswordEntryKey) - .optional()? - { - Some(key) => key, - None => { - // If the key is not set, we generate a new key and set it in the config. - // The nonce is used to differentiate between different password entries in the keyring when running - // multiple instances of the wallet on the same network. This nonce is generated once per wallet - // database. - let nonce = generate_password_entry_key_nonce(); - let key = format!( - "{}-{}-{}", - CIPHER_SEED_PASSWORD_KEYRING_ENTRY_NAME, self.config.network, nonce - ); - self.config_api().set(ConfigKey::KeyringPasswordEntryKey, &key, false)?; - key - }, - }; - - let (str_password, safe_password) = generate_password()?; - let entry = self.get_cipher_seed_password_keyring_entry(&key)?; - entry.set_password(&str_password)?; - Ok(safe_password) - } - - fn get_cipher_seed_password_keyring_entry(&self, key: &str) -> Result { - let result = keyring::Entry::new(KEYRING_ENTRIES_SERVICE, key); - - match result { - Ok(entry) => Ok(entry), - Err(keyring::Error::NoEntry) => { - // NoEntry maps to various errors in the keyring codebase, including AccessDenied, keyExpired etc. - // Entry::new says that it will only return an error if the service/user are invalid but there may be - // more errors possible e.g. AccessDenied. In any case we provide a better error than NoEntry for this - // case. We dont want IsNotFoundError to be true for this case. - Err(WalletSdkError::FailedToAccessKeyRing) - }, - Err(err) => Err(err.into()), - } - } } #[derive(Debug, thiserror::Error)] @@ -346,62 +290,14 @@ pub enum WalletSdkError { WalletStorageError(#[from] WalletStorageError), #[error("Config API error: {0}")] ConfigApiError(#[from] ConfigApiError), - #[error("OS Keyring error: {0}")] - KeyRing(#[from] keyring::Error), #[error("Key manager error: {0}")] KeyManager(#[from] KeyManagerApiError), #[error("Cipher error: {0}")] CipherError(#[from] CipherError), - #[error("Failed to generate password for cipher seed: {0}")] - PasswordGeneration(String), - #[error( - "OS keyring not supported on this device. You may have to specify an encryption password by using the \ - `--password` cli option." - )] - FailedToAccessKeyRing, + #[error("Password manager error: {0}")] + PasswordManagerError(#[from] PasswordManagerApiError), #[error(transparent)] NetworkParseError(#[from] NetworkParseError), #[error("Invariant error: {details}. This indicates a bug in the code.")] InvariantError { details: String }, } - -impl IsNotFoundError for WalletSdkError { - fn is_not_found_error(&self) -> bool { - match self { - Self::WalletStorageError(e) => e.is_not_found_error(), - Self::ConfigApiError(e) => e.is_not_found_error(), - Self::KeyManager(e) => e.is_not_found_error(), - Self::KeyRing(keyring::Error::NoEntry) => true, - Self::KeyRing(_) | - Self::CipherError(_) | - Self::PasswordGeneration(_) | - Self::InvariantError { .. } | - Self::FailedToAccessKeyRing | - Self::NetworkParseError(_) => false, - } - } -} - -// Generate a new random password. -fn generate_password() -> Result<(Zeroizing, SafePassword), WalletSdkError> { - let pg = PasswordGenerator { - length: 256, - numbers: true, - lowercase_letters: true, - uppercase_letters: true, - symbols: false, - spaces: false, - exclude_similar_characters: false, - strict: true, - }; - let generated_password = pg - .generate_one() - .map_err(|error| WalletSdkError::PasswordGeneration(error.to_string()))?; - - let safe_password = SafePassword::from(generated_password.clone()); - Ok((Zeroizing::new(generated_password), safe_password)) -} - -fn generate_password_entry_key_nonce() -> u64 { - OsRng.next_u64() -} diff --git a/crates/wallet/sdk/src/storage.rs b/crates/wallet/sdk/src/storage.rs index 0b189af198..c6d597198c 100644 --- a/crates/wallet/sdk/src/storage.rs +++ b/crates/wallet/sdk/src/storage.rs @@ -35,6 +35,9 @@ use crate::models::{ AuthoredTemplateModel, ConfidentialOutputModel, Config, + ImportedKeyId, + KeyId, + KeyType, NewAccountData, NonFungibleToken, OutputStatus, @@ -112,6 +115,10 @@ pub enum WalletStorageError { OperationError { operation: &'static str, details: String }, #[error("Data inconsistency for operation {operation}: {details}")] DataInconsistent { operation: &'static str, details: String }, + #[error("Encryption error {operation}: {details}")] + EncryptionError { operation: &'static str, details: String }, + #[error("Decryption error {operation}: {details}")] + DecryptionError { operation: &'static str, details: String }, } impl IsNotFoundError for WalletStorageError { @@ -145,6 +152,7 @@ pub trait WalletStoreReader { fn key_manager_get_all(&mut self, branch: &str) -> Result, WalletStorageError>; fn key_manager_get_active_index(&mut self, branch: &str) -> Result; fn key_manager_get_last_index(&mut self, branch: &str) -> Result; + fn key_manager_get_raw_imported_key(&mut self, id: u64) -> Result<(KeyType, Box<[u8]>), WalletStorageError>; // Config fn config_get(&mut self, key: &str) -> Result, WalletStorageError>; fn config_get_string(&mut self, key: &str) -> Result, WalletStorageError>; @@ -200,19 +208,19 @@ pub trait WalletStoreReader { addresses: I, ) -> Result, WalletStorageError>; - // Outputs - fn outputs_get_unspent_balance(&mut self, vault_id: &VaultId) -> Result; - fn outputs_get_locked_by_lock_id( + // Confidential Outputs + fn confidential_outputs_get_unspent_balance(&mut self, vault_id: &VaultId) -> Result; + fn confidential_outputs_get_locked_by_lock_id( &mut self, lock_id: WalletLockId, ) -> Result, WalletStorageError>; - fn outputs_get_by_commitment( + fn confidential_outputs_get_by_commitment( &mut self, vault_id: &VaultId, commitment: &PedersenCommitmentBytes, ) -> Result; - fn outputs_get_by_account_and_status( + fn confidential_outputs_get_by_account_and_status( &mut self, account_addr: &ComponentAddress, status: OutputStatus, @@ -300,7 +308,7 @@ pub trait WalletStoreReader { fn utxo_process_queue_fetch_batch( &mut self, batch_size: usize, - ) -> Result>, WalletStorageError>; + ) -> Result>, WalletStorageError>; } pub type TagAndPublicNoncePair = (UtxoTag, RistrettoPublicKeyBytes); @@ -319,6 +327,12 @@ pub trait WalletStoreWriter { fn key_manager_insert_or_ignore(&mut self, branch: &str, index: u64) -> Result<(), WalletStorageError>; fn key_manager_set_active_index(&mut self, branch: &str, index: u64) -> Result<(), WalletStorageError>; fn key_manager_reset_index(&mut self, branch: &str, index: u64) -> Result<(), WalletStorageError>; + fn key_manager_insert_imported_key( + &mut self, + label: &str, + encrypted_key: &[u8], + key_type: KeyType, + ) -> Result; // Config fn config_set( @@ -359,7 +373,10 @@ pub trait WalletStoreWriter { &mut self, account_name: Option<&str>, account_addr: &ComponentAddress, - owner_key_index: u64, + view_only_key_id: KeyId, + owner_key_id: Option, + owner_public_key: &RistrettoPublicKeyBytes, + associated_stealth_resources: &HashSet, is_confirmed_on_chain: bool, is_default: bool, ) -> Result<(), WalletStorageError>; @@ -395,16 +412,16 @@ pub trait WalletStoreWriter { // Resources fn resources_upsert(&mut self, address: &ResourceAddress, resource: &Resource) -> Result<(), WalletStorageError>; // Confidential Outputs - fn outputs_lock_smallest_amount( + fn confidential_outputs_lock_smallest_amount( &mut self, vault_id: &VaultId, lock_id: WalletLockId, ) -> Result; - fn outputs_insert(&mut self, output: ConfidentialOutputModel) -> Result<(), WalletStorageError>; + fn confidential_outputs_insert(&mut self, output: ConfidentialOutputModel) -> Result<(), WalletStorageError>; /// Mark outputs as finalized - fn outputs_finalize_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError>; + fn confidential_outputs_finalize_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError>; /// Release outputs that were locked and remove pending unconfirmed outputs for this proof - fn outputs_release_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError>; + fn confidential_outputs_release_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError>; // Stealth Outputs fn stealth_outputs_lock_smallest_amount( @@ -461,7 +478,7 @@ pub trait WalletStoreWriter { shard_state_versions: I, ) -> Result<(), WalletStorageError>; - fn utxo_process_queue_extend>( + fn utxo_process_queue_extend>( &mut self, resource_address: &ResourceAddress, items: I, diff --git a/crates/wallet/sdk/tests/confidential_output_api.rs b/crates/wallet/sdk/tests/confidential_output_api.rs index 5d06f48464..df282d6681 100644 --- a/crates/wallet/sdk/tests/confidential_output_api.rs +++ b/crates/wallet/sdk/tests/confidential_output_api.rs @@ -6,7 +6,7 @@ mod support; use tari_crypto::commitment::HomomorphicCommitmentFactory; use tari_engine_types::{crypto::get_commitment_factory, ToByteType}; use tari_ootle_wallet_sdk::{ - models::{ConfidentialOutputModel, OutputStatus}, + models::{ConfidentialOutputModel, KeyId, OutputStatus}, storage::{WalletStore, WalletStoreReader}, }; use tari_template_lib::models::EncryptedData; @@ -32,7 +32,7 @@ fn outputs_locked_and_released() { let locked = test .store() - .with_read_tx(|tx| tx.outputs_get_locked_by_lock_id(lock_id)) + .with_read_tx(|tx| tx.confidential_outputs_get_locked_by_lock_id(lock_id)) .unwrap(); assert!(locked.iter().any(|l| l.commitment == commitment_25)); @@ -46,7 +46,7 @@ fn outputs_locked_and_released() { let locked = test .store() - .with_read_tx(|tx| tx.outputs_get_locked_by_lock_id(lock_id)) + .with_read_tx(|tx| tx.confidential_outputs_get_locked_by_lock_id(lock_id)) .unwrap(); assert_eq!(locked.len(), 0); } @@ -70,7 +70,7 @@ fn outputs_locked_and_finalized() { let locked = test .store() - .with_read_tx(|tx| tx.outputs_get_locked_by_lock_id(proof_id)) + .with_read_tx(|tx| tx.confidential_outputs_get_locked_by_lock_id(proof_id)) .unwrap(); assert!(locked.iter().any(|l| l.commitment == commitment_25)); @@ -88,7 +88,8 @@ fn outputs_locked_and_finalized() { commitment: commitment_change, value: 24.into(), sender_public_nonce: None, - encryption_secret_key_index: 0, + view_only_key_id: KeyId::derived(0), + owner_key_id: Some(KeyId::derived(0)), encrypted_data: EncryptedData::try_from(vec![0; EncryptedData::min_size()]).unwrap(), public_asset_tag: None, status: OutputStatus::LockedUnconfirmed, @@ -103,16 +104,18 @@ fn outputs_locked_and_finalized() { { let mut tx = test.store().create_read_tx().unwrap(); - let locked = tx.outputs_get_locked_by_lock_id(proof_id).unwrap(); + let locked = tx.confidential_outputs_get_locked_by_lock_id(proof_id).unwrap(); assert_eq!(locked.len(), 0); let unspent = tx - .outputs_get_by_account_and_status(&Test::test_account_address(), OutputStatus::Unspent) + .confidential_outputs_get_by_account_and_status(&Test::test_account_address(), OutputStatus::Unspent) .unwrap(); assert!(unspent.iter().any(|l| l.commitment == commitment_change)); assert!(unspent.iter().any(|l| l.commitment == commitment_100)); assert_eq!(unspent.len(), 2); - let balance = tx.outputs_get_unspent_balance(&Test::test_vault_address()).unwrap(); + let balance = tx + .confidential_outputs_get_unspent_balance(&Test::test_vault_address()) + .unwrap(); assert_eq!(balance, 124); } } diff --git a/crates/wallet/sdk/tests/support/harness.rs b/crates/wallet/sdk/tests/support/harness.rs index 0511027870..32bf1af906 100644 --- a/crates/wallet/sdk/tests/support/harness.rs +++ b/crates/wallet/sdk/tests/support/harness.rs @@ -12,7 +12,8 @@ use tari_engine_types::{ }; use tari_ootle_common_types::{optional::Optional, shard::Shard, Network, StateVersion}; use tari_ootle_wallet_sdk::{ - models::{ConfidentialOutputModel, OutputStatus, UtxoUpdateSet, WalletLockId}, + cipher_seed::CipherSeedRestore, + models::{ConfidentialOutputModel, KeyId, OutputStatus, UtxoUpdateSet, WalletLockId}, network::{SubstateQueryResult, TransactionQueryResult, WalletNetworkInterface}, storage::TagAndPublicNoncePair, WalletSdk, @@ -44,10 +45,18 @@ impl Test { override_keyring_password: Some(SafePassword::from_str("SuuuCh Sekret W0W").unwrap()), }) .unwrap(); - sdk.initialize_cipher_seed(None).unwrap(); + sdk.initialize_cipher_seed(CipherSeedRestore::CreateNewIfRequired) + .unwrap(); let accounts_api = sdk.accounts_api(); accounts_api - .add_account(Some("test"), &Test::test_account_address(), 0, true, true) + .add_account( + Some("test"), + &Test::test_account_address(), + KeyId::derived(0), + KeyId::derived(0), + true, + true, + ) .unwrap(); accounts_api .add_vault( @@ -93,7 +102,8 @@ impl Test { commitment, value: amount, sender_public_nonce: None, - encryption_secret_key_index: 0, + view_only_key_id: KeyId::derived(0), + owner_key_id: Some(KeyId::derived(0)), encrypted_data: EncryptedData::try_from(vec![0; EncryptedData::min_size()]).unwrap(), public_asset_tag: None, status: OutputStatus::Unspent, diff --git a/crates/wallet/sdk_services/Cargo.toml b/crates/wallet/sdk_services/Cargo.toml index 6cb226b5a7..afc9ce007e 100644 --- a/crates/wallet/sdk_services/Cargo.toml +++ b/crates/wallet/sdk_services/Cargo.toml @@ -16,7 +16,6 @@ tari_engine_types = { workspace = true } tari_template_lib = { workspace = true } tari_indexer_client = { workspace = true, features = ["client"], optional = true } tari_transaction = { workspace = true } -tari_transaction_components = { workspace = true } tari_template_builtin = { workspace = true } tari_crypto = { workspace = true } diff --git a/crates/wallet/sdk_services/src/account_monitor.rs b/crates/wallet/sdk_services/src/account_monitor.rs index 7eaf1fd75a..39c37527a6 100644 --- a/crates/wallet/sdk_services/src/account_monitor.rs +++ b/crates/wallet/sdk_services/src/account_monitor.rs @@ -59,6 +59,8 @@ pub struct AccountMonitor { request_rx: mpsc::Receiver, pending_accounts: HashMap, utxo_scanner_handle: UtxoScannerHandle, + periodic_scan_interval: Duration, + enable_periodic_scanning_with_utxos: bool, shutdown_signal: ShutdownSignal, } @@ -82,21 +84,35 @@ where wallet_sdk, request_rx, pending_accounts: HashMap::new(), + periodic_scan_interval: Duration::from_secs(60), utxo_scanner_handle, + enable_periodic_scanning_with_utxos: true, shutdown_signal, }, AccountMonitorHandle { sender: request_tx }, ) } + pub fn with_periodic_scan_interval(mut self, interval: Duration) -> Self { + self.periodic_scan_interval = interval; + self + } + + pub fn disable_periodic_scanning_with_utxos(mut self) -> Self { + self.enable_periodic_scanning_with_utxos = false; + self + } + pub async fn run(mut self) -> Result<(), anyhow::Error> { + info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor started"); let mut events_subscription = self.notify.subscribe(); - let mut poll_interval = time::interval(Duration::from_secs(60)); + let mut poll_interval = time::interval(self.periodic_scan_interval); poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { tokio::select! { _ = self.shutdown_signal.wait() => { + info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor shutting down"); break Ok(()); } @@ -119,9 +135,14 @@ where } async fn handle_request(&self, req: AccountMonitorRequest) { + debug!(target: LOG_TARGET, "👁️‍🗨️ Account monitor received request: {:?}", req); match req { - AccountMonitorRequest::RefreshAccount { account, reply } => { - let _ignore = reply.send(self.refresh_account(account).await); + AccountMonitorRequest::RefreshAccount { + account, + scan_for_utxos, + reply, + } => { + let _ignore = reply.send(self.refresh_account(account, scan_for_utxos).await); }, } } @@ -137,15 +158,13 @@ where // TODO: There could be more than 100 accounts let accounts = accounts_api.get_many(0, 100)?; for account in accounts { - info!( - target: LOG_TARGET, - "👁️‍🗨️ Refreshing account {}", account - ); - let is_updated = self.refresh_account(account.component_address).await?; + let is_updated = self + .refresh_account(*account.component_address(), self.enable_periodic_scanning_with_utxos) + .await?; if is_updated { self.notify.notify(AccountChangedEvent { - account_address: account.component_address, + account_address: *account.component_address(), }); } else { info!( @@ -157,7 +176,15 @@ where Ok(()) } - async fn refresh_account(&self, account_address: ComponentAddress) -> Result { + async fn refresh_account( + &self, + account_address: ComponentAddress, + scan_for_utxos: bool, + ) -> Result { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Refreshing account {}", account_address + ); let substate_api = self.wallet_sdk.substate_api(); let accounts_api = self.wallet_sdk.accounts_api(); @@ -166,6 +193,14 @@ where return Ok(false); }; + let mut associated_resources = accounts_api.get_associated_stealth_resources(account.component_address())?; + associated_resources.insert(XTR); + // Stealth outputs reference resources, so we need to ensure we have an entry for them. If they are already + // cached, this is a relatively cheap check. + for addr in &associated_resources { + self.ensure_resource_is_cached(addr).await?; + } + let mut is_updated = false; let maybe_scan_result = substate_api .fetch_substate_from_network(&account_address.into(), None) @@ -182,8 +217,10 @@ where } // Otherwise, the account is not on-chain, so we wouldn't expect the indexer to have it - // Scan for associated stealth resources - self.refresh_stealth_utxos(account_address)?; + if scan_for_utxos { + // Scan for associated stealth resources + self.refresh_stealth_utxos(associated_resources, account_address)?; + } return Ok(false); }; @@ -288,19 +325,19 @@ where } } - // Scan for all stealth resources - self.refresh_stealth_utxos(account_address)?; + if scan_for_utxos { + // Scan for all stealth resources + self.refresh_stealth_utxos(associated_resources, account_address)?; + } Ok(is_updated) } - fn refresh_stealth_utxos(&self, account_address: ComponentAddress) -> Result<(), AccountMonitorError> { - let mut stealth_resources = self - .wallet_sdk - .accounts_api() - .get_associated_stealth_resources(&account_address)?; - stealth_resources.insert(XTR); - + fn refresh_stealth_utxos( + &self, + stealth_resources: HashSet, + account_address: ComponentAddress, + ) -> Result<(), AccountMonitorError> { info!( target: LOG_TARGET, "👁️‍🗨️ Requesting UTXO scan for account {} for {} stealth resource(s)", @@ -348,7 +385,7 @@ where account_address ); - let resource = self.fetch_resource(*latest_vault.resource_address()).await?; + let resource = self.fetch_and_cache_resource(latest_vault.resource_address()).await?; let token_symbol = resource.token_symbol().map(|s| s.to_string()); let divisibility = resource.divisibility(); @@ -402,9 +439,8 @@ where // If the vault has revealed stealth tokens, we should also to scan for UTXOs if latest_vault.resource_type().is_stealth() { - self.wallet_sdk - .accounts_api() - .associate_stealth_resource(&account_address, *latest_vault.resource_address())?; + self.associate_resource_with_account(&account_address, *latest_vault.resource_address()) + .await?; } let outputs_api = self.wallet_sdk.confidential_outputs_api(); @@ -431,6 +467,31 @@ where Ok(()) } + async fn associate_resource_with_account( + &self, + account_address: &ComponentAddress, + resource_address: ResourceAddress, + ) -> Result<(), AccountMonitorError> { + let accounts_api = self.wallet_sdk.accounts_api(); + self.ensure_resource_is_cached(&resource_address).await?; + accounts_api.associate_stealth_resource(account_address, resource_address)?; + Ok(()) + } + + async fn ensure_resource_is_cached(&self, resource_address: &ResourceAddress) -> Result<(), AccountMonitorError> { + let resources_api = self.wallet_sdk.resources_api(); + if resources_api.exists(resource_address)? { + return Ok(()); + } + debug!( + target: LOG_TARGET, + "Resource {} not in local store. Fetching from network.", + resource_address + ); + let _resource = self.fetch_and_cache_resource(resource_address).await?; + Ok(()) + } + async fn update_vault_nfts( &self, vault_id: VaultId, @@ -729,16 +790,16 @@ where Ok(()) } - async fn fetch_resource(&self, resx_addr: ResourceAddress) -> Result { - if let Some(resx) = self.wallet_sdk.resources_api().get(&resx_addr).optional()? { + async fn fetch_and_cache_resource(&self, resx_addr: &ResourceAddress) -> Result { + if let Some(resx) = self.wallet_sdk.resources_api().get(resx_addr).optional()? { return Ok(resx); } - let substate = self.fetch_substate(&SubstateId::Resource(resx_addr)).await?; + let substate = self.fetch_substate(&SubstateId::Resource(*resx_addr)).await?; let resx = substate.into_substate_value().into_resource().ok_or_else(|| { AccountMonitorError::UnexpectedSubstate(format!("Expected {} to be a resource.", resx_addr)) })?; - self.wallet_sdk.resources_api().upsert_resource(&resx_addr, &resx)?; + self.wallet_sdk.resources_api().upsert_resource(resx_addr, &resx)?; Ok(resx) } @@ -773,7 +834,7 @@ where if accounts_api.has_vault(&vault_id)? { return Ok(()); } - let maybe_resource = match self.fetch_resource(*vault.resource_address()).await { + let maybe_resource = match self.fetch_and_cache_resource(vault.resource_address()).await { Ok(r) => Some(r), Err(e) => { warn!( @@ -836,6 +897,7 @@ where enum AccountMonitorRequest { RefreshAccount { account: ComponentAddress, + scan_for_utxos: bool, reply: Reply>, }, } @@ -846,11 +908,27 @@ pub struct AccountMonitorHandle { } impl AccountMonitorHandle { + /// Triggers an immediate refresh of the specified account. Returns `true` if the account was updated, otherwise + /// `false`. pub async fn refresh_account(&self, account: ComponentAddress) -> Result { let (reply_tx, reply_rx) = oneshot::channel(); self.sender .send(AccountMonitorRequest::RefreshAccount { account, + scan_for_utxos: false, + reply: reply_tx, + }) + .await + .map_err(|_| AccountMonitorError::ServiceShutdown)?; + reply_rx.await.map_err(|_| AccountMonitorError::ServiceShutdown)? + } + + pub async fn refresh_account_with_utxos(&self, account: ComponentAddress) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.sender + .send(AccountMonitorRequest::RefreshAccount { + account, + scan_for_utxos: true, reply: reply_tx, }) .await diff --git a/crates/wallet/sdk_services/src/account_recovery/service.rs b/crates/wallet/sdk_services/src/account_recovery/service.rs index 4b1da48db0..cf89916190 100644 --- a/crates/wallet/sdk_services/src/account_recovery/service.rs +++ b/crates/wallet/sdk_services/src/account_recovery/service.rs @@ -13,12 +13,12 @@ use tari_ootle_common_types::{ }; use tari_ootle_wallet_sdk::{ apis::{config::ConfigKey, key_manager::KeyBranch}, + models::{DerivedWalletKey, KeyId}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, }; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; -use tari_transaction_components::key_manager::tari_key_manager::DerivedKey; use tokio::time; use crate::{account_monitor::AccountMonitorHandle, account_recovery::AccountRecoveryError}; @@ -73,7 +73,7 @@ where let mut not_found_accounts_count = 0; let mut found_accounts_count = 0; let initial_key_index = match key_manager_api.get_active_key(KeyBranch::Account) { - Ok((key_index, _)) => key_index, + Ok(key) => key.key_index, Err(err) => { error!(target: LOG_TARGET, "Error getting active key: {err}. Scanning failed..."); return; @@ -132,11 +132,7 @@ where } // Set a flag to indicate that the wallet has completed recovery - if let Err(err) = self - .wallet_sdk - .config_api() - .set(ConfigKey::RecoveryNeeded, &false, false) - { + if let Err(err) = self.wallet_sdk.config_api().set(ConfigKey::RecoveryNeeded, &false) { error!(target: LOG_TARGET, "Error setting recovery needed flag: {err}"); } @@ -145,7 +141,7 @@ where /// Attempt to recover an account by the provided public key. Returning true if the account was found on-chain, /// false if not. - async fn try_recover_account(&self, key: &DerivedKey) -> Result { + async fn try_recover_account(&self, key: &DerivedWalletKey) -> Result { let network_interface = self.wallet_sdk.get_network_interface(); let public_key = RistrettoPublicKey::from_secret_key(&key.key).to_byte_type(); @@ -169,14 +165,17 @@ where self.wallet_sdk.accounts_api().add_account( Some(format!("recovered-account-{}", key.key_index).as_str()), &account_addr, - key.key_index, + key.as_key_id(), + key.as_key_id(), false, // if this is the first account, set it as the default key.key_index == 0, )?; // Update UTXOs - self.account_monitor_handle.refresh_account(account_addr).await?; + self.account_monitor_handle + .refresh_account_with_utxos(account_addr) + .await?; // Count this as not found for the purposes of stopping the scan after N not founds Ok(false) }, @@ -193,13 +192,13 @@ where })?; if component.owner_key.is_none() { - warn!(target: LOG_TARGET, "⚠️ Account {} has no owner key. This wallet may not be able tio sign for this account", account_addr); + warn!(target: LOG_TARGET, "⚠️ Account {} has no owner key. This wallet may not be able to use this account", account_addr); }; if component.owner_key.is_some_and(|pk| pk != public_key) { warn!( target: LOG_TARGET, - "⚠️ Account {} has a different owner key {} than the one derived from the seed key {}. This wallet may not be able to sign for this account", + "⚠️ Account {} has a different owner key {} than the one derived from the seed key {}. This wallet may not be able to use this account", account_addr, component.owner_key.unwrap_or_default(), public_key @@ -217,14 +216,17 @@ where self.wallet_sdk.accounts_api().add_account( Some(format!("recovered-account-{}", key.key_index).as_str()), &account_addr, - key.key_index, + KeyId::derived(key.key_index), + KeyId::derived(key.key_index), true, // if this is the first account, set it as the default key.key_index == 0, )?; // Update vaults, UTXOs, nfts etc - self.account_monitor_handle.refresh_account(account_addr).await?; + self.account_monitor_handle + .refresh_account_with_utxos(account_addr) + .await?; Ok(true) }, diff --git a/crates/wallet/sdk_services/src/utxo_scanner/error.rs b/crates/wallet/sdk_services/src/utxo_scanner/error.rs index d5ff6f018f..7fa914f5d9 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/error.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/error.rs @@ -3,7 +3,12 @@ use tari_ootle_common_types::optional::IsNotFoundError; use tari_ootle_wallet_sdk::{ - apis::{config::ConfigApiError, key_manager::KeyManagerApiError, stealth_outputs::StealthOutputsApiError}, + apis::{ + accounts::AccountsApiError, + config::ConfigApiError, + key_manager::KeyManagerApiError, + stealth_outputs::StealthOutputsApiError, + }, storage::WalletStorageError, }; @@ -21,6 +26,8 @@ pub enum StealthScannerApiError { StealthOutputsError(#[from] StealthOutputsApiError), #[error("Key manager error: {0}")] KeyManagerError(#[from] KeyManagerApiError), + #[error("Account API error: {0}")] + AccountApiError(#[from] AccountsApiError), } impl IsNotFoundError for StealthScannerApiError { diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs index d8d132ff18..0caa38a048 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs @@ -27,20 +27,23 @@ where Self { sdk } } - pub async fn scan_and_recover_utxos( + pub async fn scan_and_enqueue_utxos( &self, account: &AccountWithAddress, resource_address: &ResourceAddress, notify_tx: &watch::Sender<()>, - ) -> Result<(), StealthScannerApiError> { + ) -> Result { let network = self.sdk.config_api().get_network()?; - let view_key = self.sdk.key_manager_api().derive_view_only_key(account.key_index())?; + let view_key = self + .sdk + .key_manager_api() + .get_view_only_key(account.view_only_key_id())?; let mut scanner_round = UtxoScannerRound::new(network, &self.sdk, notify_tx, account, &view_key, resource_address); - scanner_round.scan_for_utxo_updates().await?; + let num_found = scanner_round.scan_for_utxo_updates().await?; - Ok(()) + Ok(num_found) } } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs index 6822431034..ca091e0f2f 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs @@ -14,13 +14,12 @@ use tari_ootle_common_types::{ StateVersion, }; use tari_ootle_wallet_sdk::{ - models::{AccountWithAddress, UtxoSpent, UtxoUnspent, WalletUtxoUpdate}, + models::{AccountWithAddress, Key, UtxoSpent, UtxoUnspent, WalletUtxoUpdate}, network::{StatusResponseError, WalletNetworkInterface}, storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, WalletSdk, }; -use tari_template_lib::models::ResourceAddress; -use tari_transaction_components::key_manager::tari_key_manager::DerivedKey; +use tari_template_lib::models::{ComponentAddress, ResourceAddress}; use tokio::sync::watch; use crate::utxo_scanner::StealthScannerApiError; @@ -32,14 +31,14 @@ const NUM_PRESHARDS: NumPreshards = NumPreshards::P256; pub struct UtxoScannerRound<'a, TStore, TNetworkInterface> { network: Network, account: &'a AccountWithAddress, - view_key: &'a DerivedKey, + view_key: &'a Key, resource_address: &'a ResourceAddress, sdk: &'a WalletSdk, notify_tx: &'a watch::Sender<()>, shard_state_versions_to_set: HashMap, - utxos_to_recover: Vec<(u64, UtxoUnspent)>, + utxos_to_recover: Vec<(ComponentAddress, UtxoUnspent)>, utxos_to_spend: Vec, } @@ -54,7 +53,7 @@ where sdk: &'a WalletSdk, notify_tx: &'a watch::Sender<()>, account: &'a AccountWithAddress, - view_key: &'a DerivedKey, + view_key: &'a Key, resource_address: &'a ResourceAddress, ) -> Self { Self { @@ -70,22 +69,22 @@ where } } - pub async fn scan_for_utxo_updates(&mut self) -> Result<(), StealthScannerApiError> { - let mut any_found = false; + pub async fn scan_for_utxo_updates(&mut self) -> Result { + let mut num_found = 0; loop { if !self.scan().await? { break; } - any_found = true; + num_found += 1; } - if any_found { + if num_found > 0 { // Notify that there are new UTXOs to process debug!(target: LOG_TARGET, "Notifying that new UTXOs are available for processing"); let _ignore = self.notify_tx.send(()); } - Ok(()) + Ok(num_found) } async fn scan(&mut self) -> Result { @@ -154,7 +153,7 @@ where "🏷️ Stealth output tag {} matches. Queueing for recovery.", unspent.tag ); - self.utxos_to_recover.push((self.account.key_index(), unspent)); + self.utxos_to_recover.push((*self.account.component_address(), unspent)); } }, WalletUtxoUpdate::Spent(spent) => { @@ -218,7 +217,7 @@ where let tag = self.sdk.stealth_crypto_api().derive_stealth_output_tag( self.network, - &self.view_key.key, + &self.view_key.secret, &public_nonce, self.resource_address, ); diff --git a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs index a6a0713e26..11ed4956ec 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs @@ -10,19 +10,30 @@ use tari_ootle_common_types::{ Network, }; use tari_ootle_wallet_sdk::{ + models::AccountAndViewKeys, network::{StatusResponseError, WalletNetworkInterface}, storage::{WalletStore, WalletStoreReader, WalletStoreWriter}, WalletSdk, }; -use tari_template_lib::models::{ResourceAddress, UtxoAddress, UtxoId}; -use tokio::sync::watch; +use tari_template_lib::models::{ComponentAddress, ResourceAddress, UtxoAddress, UtxoId}; +use tokio::sync::{broadcast, watch}; use crate::utxo_scanner::StealthScannerApiError; const LOG_TARGET: &str = "tari::ootle::wallet_services::utxo_recovery"; +#[derive(Debug, Clone)] +pub enum UtxoRecoveryEvent { + UtxoRecoveryRoundStarted { round_id: usize }, + UtxoRecoveryRoundBatchStarted { round_id: usize, batch_size: usize }, + UtxoRecovered { utxo_address: UtxoAddress }, + UtxoRecoveryRoundCompleted { round_id: usize }, +} + pub struct UtxoRecovery { sdk: WalletSdk, + events: Option>, + round_id: usize, } impl UtxoRecovery @@ -32,7 +43,16 @@ where TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { pub fn new(sdk: WalletSdk) -> Self { - Self { sdk } + Self { + sdk, + events: None, + round_id: 0, + } + } + + pub fn with_events(mut self, events: broadcast::Sender) -> Self { + self.events = Some(events); + self } pub async fn run(mut self, mut waker: watch::Receiver<()>) -> anyhow::Result<()> { @@ -68,7 +88,14 @@ where Ok(()) } - async fn process_utxo_validation_queue(&mut self) -> Result<(), StealthScannerApiError> { + fn publish_event(&self, event: UtxoRecoveryEvent) { + if let Some(events) = &self.events { + let _ = events.send(event); + } + } + + pub async fn process_utxo_validation_queue(&mut self) -> Result<(), StealthScannerApiError> { + let mut start_event_published = false; loop { let batch = self .sdk @@ -77,9 +104,33 @@ where if batch.is_empty() { debug!(target: LOG_TARGET, "✅ No more UTXOs to process"); + + if self.round_id == 0 { + self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundStarted { + round_id: self.round_id, + }); + self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundCompleted { + round_id: self.round_id, + }); + + self.round_id += 1; + } return Ok(()); } + if !start_event_published { + self.round_id += 1; + self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundStarted { + round_id: self.round_id, + }); + start_event_published = true; + } + + self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundBatchStarted { + round_id: self.round_id, + batch_size: batch.len(), + }); + for (resource_addr, tag_and_nonce_to_view_key_map) in &batch { if tag_and_nonce_to_view_key_map.is_empty() { error!(target: LOG_TARGET, "❓️ NEVER HAPPEN: Asked indexer for zero UTXOs for resource {}.", resource_addr); @@ -130,13 +181,13 @@ where }) .filter_map(|(id, output, is_frozen)| { let tag_and_nonce_pair = (output.tag, output.output.public_nonce); - let Some(view_key_index) = tag_and_nonce_to_view_key_map.get(&tag_and_nonce_pair).copied() else { + let Some(account_addr) = tag_and_nonce_to_view_key_map.get(&tag_and_nonce_pair).copied() else { warn!(target: LOG_TARGET, "❓️ NEVER HAPPEN: Indexer returned UTXO with tag {}, nonce {} that we didn't request. Ignoring", output.tag, output.output.public_nonce); return None; }; Some(FoundUtxo { - view_key_index, + account_addr, id, output, is_frozen, @@ -146,6 +197,12 @@ where self.process_recovered_utxos(*resource_addr, utxos)?; } + + if start_event_published { + self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundCompleted { + round_id: self.round_id, + }); + } } } @@ -179,16 +236,26 @@ where resource_address: ResourceAddress, found: FoundUtxo, ) -> Result { + let account = self.sdk.accounts_api().get_account_by_address(&found.account_addr)?; let view_only_key = self .sdk .key_manager_api() - .derive_view_only_keypair(found.view_key_index)?; + .get_view_only_key(account.view_only_key_id())?; + let account_key = account + .owner_key_id() + .map(|key_id| self.sdk.key_manager_api().get_account_owner_key(key_id)) + .transpose()?; + let keys = AccountAndViewKeys { + account_public_key: *account.owner_public_key(), + account_key, + view_only_key, + }; let outputs_api = self.sdk.stealth_outputs_api(); let address = UtxoAddress::new(resource_address, found.id); let commitment = found.id.into_commitment_bytes(); let Some(output) = outputs_api.validate_utxo( - array::from_ref(&view_only_key), + array::from_ref(&keys), network, resource_address, commitment, @@ -209,16 +276,21 @@ where }; outputs_api.upsert_utxo(&output)?; + self.sdk.store().with_write_tx(|tx| { tx.utxo_process_queue_remove_item(resource_address, found.output.tag, found.output.output.public_nonce) })?; - info!(target: LOG_TARGET, "💰️ Recovered stealth output {} for account {}", address, view_only_key.public_key); + + self.publish_event(UtxoRecoveryEvent::UtxoRecovered { + utxo_address: UtxoAddress::new(resource_address, found.id), + }); + info!(target: LOG_TARGET, "💰️ Recovered stealth output {} for account {}", address, keys.account_public_key); Ok(true) } } struct FoundUtxo { - view_key_index: u64, + account_addr: ComponentAddress, output: UtxoOutput, id: UtxoId, is_frozen: bool, diff --git a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs index 806a896971..0c5c1cf077 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs @@ -186,7 +186,7 @@ where info!(target: LOG_TARGET, "🔍 Scanning for UTXOs for {}", task); let account = sdk.accounts_api().get_account_by_address(&task.account_address)?; UtxoScanner::new(sdk) - .scan_and_recover_utxos(&account, &task.resource_address, ¬ify_tx) + .scan_and_enqueue_utxos(&account, &task.resource_address, ¬ify_tx) .await?; Ok(()) diff --git a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql index b0b8d0a78d..b8d28820cb 100644 --- a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql +++ b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql @@ -13,6 +13,18 @@ CREATE TABLE key_manager_states CREATE UNIQUE INDEX key_manager_states_uniq_branch_seed_index on key_manager_states (branch_seed, `index`); +CREATE TABLE key_manager_imported_keys +( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + label TEXT NOT NULL, + encrypted_secret BLOB NOT NULL, + key_type TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE UNIQUE INDEX key_manager_imported_keys_uniq_label on key_manager_imported_keys (label); + + -- Config CREATE TABLE config @@ -72,7 +84,9 @@ CREATE TABLE accounts id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT NULL, address TEXT NOT NULL, - owner_key_index BIGINT NOT NULL, + owner_public_key TEXT NOT NULL, + view_only_key_id TEXT NOT NULL, + owner_key_id TEXT NULL, is_default BOOLEAN NOT NULL DEFAULT 0, is_confirmed_on_chain BOOLEAN NOT NULL, stealth_resources TEXT NOT NULL DEFAULT '[]', @@ -81,6 +95,7 @@ CREATE TABLE accounts ); CREATE UNIQUE INDEX accounts_uniq_address ON accounts (address); +CREATE UNIQUE INDEX accounts_uniq_owner_public_key ON accounts (owner_public_key); CREATE UNIQUE INDEX accounts_uniq_name ON accounts (name) WHERE name IS NOT NULL; -- Vaults @@ -128,21 +143,22 @@ CREATE UNIQUE INDEX resources_uniq_address ON resources (address); -- Confidential Outputs CREATE TABLE confidential_outputs ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL REFERENCES accounts (id), - vault_id INTEGER NOT NULL REFERENCES vaults (id), - commitment TEXT NOT NULL, - value BIGINT NOT NULL, - sender_public_nonce TEXT NULL, - encryption_secret_key_index BIGINT NOT NULL, - public_asset_tag TEXT NULL, + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES accounts (id), + vault_id INTEGER NOT NULL REFERENCES vaults (id), + commitment TEXT NOT NULL, + value BIGINT NOT NULL, + sender_public_nonce TEXT NULL, + view_only_key_id TEXT NOT NULL, + owner_key_id TEXT NULL, + public_asset_tag TEXT NULL, -- Status can be "Unspent", "Spent", "Locked", "LockedUnconfirmed", "Invalid" - status TEXT NOT NULL, - locked_at DATETIME NULL, - lock_id INTEGER NULL, - encrypted_data blob NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + status TEXT NOT NULL, + locked_at DATETIME NULL, + lock_id INTEGER NULL, + encrypted_data blob NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX confidential_outputs_uniq_commitment ON confidential_outputs (commitment); @@ -215,24 +231,25 @@ CREATE TABLE webauthn_registration_passkeys -- Stealth Outputs CREATE TABLE stealth_outputs ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - owner_account_id INTEGER NOT NULL, - resource_address TEXT NOT NULL, - commitment TEXT NOT NULL, - value TEXT NOT NULL, - sender_public_nonce TEXT NOT NULL, + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + owner_account_id INTEGER NOT NULL, + resource_address TEXT NOT NULL, + commitment TEXT NOT NULL, + value TEXT NOT NULL, + sender_public_nonce TEXT NOT NULL, -- Status can be "Unspent", "Spent", "Locked", "LockedUnconfirmed", "Invalid" - status TEXT NOT NULL, - locked_at DATETIME NULL, - lock_id INTEGER NULL, - encryption_secret_key_index BIGINT NOT NULL, - encrypted_data BLOB NOT NULL DEFAULT '', - tag_byte INTEGER NOT NULL, - is_burnt BOOLEAN NOT NULL DEFAULT 0, - is_frozen BOOLEAN NOT NULL DEFAULT 0, - is_on_chain BOOLEAN NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + status TEXT NOT NULL, + locked_at DATETIME NULL, + lock_id INTEGER NULL, + view_only_key_id TEXT NOT NULL, + owner_key_id TEXT NULL, + encrypted_data BLOB NOT NULL DEFAULT '', + tag_byte INTEGER NOT NULL, + is_burnt BOOLEAN NOT NULL DEFAULT 0, + is_frozen BOOLEAN NOT NULL DEFAULT 0, + is_on_chain BOOLEAN NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX stealth_outputs_uniq_resource_addr_commitment ON stealth_outputs (resource_address, commitment); @@ -256,13 +273,13 @@ CREATE INDEX shard_state_versions_account_resource_shard_state_version_idx ON sh -- UTXO process queue CREATE TABLE utxo_process_queue ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - account_key_index BIGINT NOT NULL, - resource_address TEXT NOT NULL, - utxo_tag INT NOT NULL, - public_nonce TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + resource_address TEXT NOT NULL, + utxo_tag INT NOT NULL, + public_nonce TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX utxo_process_queue_account_resource_tag_nonce_uniq - ON utxo_process_queue (account_key_index, resource_address, utxo_tag, public_nonce); \ No newline at end of file + ON utxo_process_queue (account_id, resource_address, utxo_tag, public_nonce); \ No newline at end of file diff --git a/crates/wallet/storage_sqlite/src/models/account.rs b/crates/wallet/storage_sqlite/src/models/account.rs index 80a42d72f5..17e27303a9 100644 --- a/crates/wallet/storage_sqlite/src/models/account.rs +++ b/crates/wallet/storage_sqlite/src/models/account.rs @@ -5,7 +5,10 @@ use diesel::{Identifiable, Queryable}; use tari_ootle_wallet_sdk::storage::WalletStorageError; use time::PrimitiveDateTime; -use crate::schema::accounts; +use crate::{ + schema::accounts, + serialization::{deserialize_hex_try_from, deserialize_json}, +}; #[derive(Debug, Clone, Queryable, Identifiable)] #[diesel(table_name = accounts)] @@ -13,7 +16,9 @@ pub struct Account { pub id: i32, pub name: Option, pub address: String, - pub owner_key_index: i64, + pub owner_public_key: String, + pub view_only_key_id: String, + pub owner_key_id: Option, pub is_default: bool, pub is_confirmed_on_chain: bool, pub _stealth_resource_address: String, @@ -30,7 +35,9 @@ impl Account { item: "address", details: format!("Invalid address: {}: {e}", self.address), })?, - key_index: self.owner_key_index as u64, + owner_key_id: self.owner_key_id.as_ref().map(deserialize_json).transpose()?, + view_only_key_id: deserialize_json(&self.view_only_key_id)?, + owner_public_key: deserialize_hex_try_from(&self.owner_public_key)?, is_confirmed_on_chain: self.is_confirmed_on_chain, is_default: self.is_default, }) diff --git a/crates/wallet/storage_sqlite/src/models/output.rs b/crates/wallet/storage_sqlite/src/models/confidential_output.rs similarity index 91% rename from crates/wallet/storage_sqlite/src/models/output.rs rename to crates/wallet/storage_sqlite/src/models/confidential_output.rs index 67125f77cf..861f5c98fa 100644 --- a/crates/wallet/storage_sqlite/src/models/output.rs +++ b/crates/wallet/storage_sqlite/src/models/confidential_output.rs @@ -8,7 +8,7 @@ use tari_template_lib::{ }; use time::PrimitiveDateTime; -use crate::schema::confidential_outputs; +use crate::{schema::confidential_outputs, serialization::deserialize_json}; #[derive(Debug, Clone, Identifiable, Queryable)] #[diesel(table_name = confidential_outputs)] @@ -20,7 +20,8 @@ pub struct ConfidentialOutput { // TODO: change to a string to allow arbitrary precision pub value: i64, pub sender_public_nonce: Option, - pub encryption_secret_key_index: i64, + pub view_only_key_id: String, + pub owner_key_id: Option, pub public_asset_tag: Option, pub status: String, pub locked_at: Option, @@ -60,7 +61,8 @@ impl ConfidentialOutput { sender_public_nonce: self .sender_public_nonce .map(|nonce| RistrettoPublicKeyBytes::from_hex(&nonce).unwrap()), - encryption_secret_key_index: self.encryption_secret_key_index as u64, + view_only_key_id: deserialize_json(&self.view_only_key_id)?, + owner_key_id: self.owner_key_id.map(|id| deserialize_json(&id)).transpose()?, encrypted_data: EncryptedData::try_from(self.encrypted_data).map_err(|len| { WalletStorageError::DecodingError { operation: "try_into_output", diff --git a/crates/wallet/storage_sqlite/src/models/mod.rs b/crates/wallet/storage_sqlite/src/models/mod.rs index 8d99f442c4..ec627dcad1 100644 --- a/crates/wallet/storage_sqlite/src/models/mod.rs +++ b/crates/wallet/storage_sqlite/src/models/mod.rs @@ -5,7 +5,7 @@ mod account; mod config; -mod output; +mod confidential_output; mod substate; @@ -22,9 +22,9 @@ mod webauthn_registrations; pub use account::*; pub use authored_template::*; +pub use confidential_output::*; pub use config::*; pub use non_fungible_tokens::*; -pub use output::*; pub use resource::*; pub use stealth_output::*; pub use substate::Substate; diff --git a/crates/wallet/storage_sqlite/src/models/resource.rs b/crates/wallet/storage_sqlite/src/models/resource.rs index 65cfffd8e6..cfef8ce827 100644 --- a/crates/wallet/storage_sqlite/src/models/resource.rs +++ b/crates/wallet/storage_sqlite/src/models/resource.rs @@ -6,7 +6,10 @@ use std::str::FromStr; use diesel::{Identifiable, Queryable}; use tari_engine_types::resource::Resource; use tari_ootle_wallet_sdk::storage::WalletStorageError; -use tari_template_lib::{models::ResourceAddress, prelude::ResourceType, types::Amount}; +use tari_template_lib::{ + models::ResourceAddress, + types::{Amount, ResourceType}, +}; use time::PrimitiveDateTime; use crate::{ @@ -88,16 +91,13 @@ impl ResourceModel { }) .transpose()?; let view_key = self.view_key.as_ref().map(deserialize_hex_try_from).transpose()?; - let auth_hook = self - .auth_hook - .as_ref() - .map(|s| deserialize_json(s)) - .transpose() - .map_err(|e| WalletStorageError::DecodingError { + let auth_hook = self.auth_hook.as_ref().map(deserialize_json).transpose().map_err(|e| { + WalletStorageError::DecodingError { operation: "try_convert", item: "resource.auth_hook", details: e.to_string(), - })?; + } + })?; let resource = Resource::load( resource_type, diff --git a/crates/wallet/storage_sqlite/src/models/stealth_output.rs b/crates/wallet/storage_sqlite/src/models/stealth_output.rs index 585470463f..e5a7029e23 100644 --- a/crates/wallet/storage_sqlite/src/models/stealth_output.rs +++ b/crates/wallet/storage_sqlite/src/models/stealth_output.rs @@ -12,7 +12,10 @@ use tari_template_lib::{ }; use time::PrimitiveDateTime; -use crate::{schema::stealth_outputs, serialization::deserialize_hex_try_from}; +use crate::{ + schema::stealth_outputs, + serialization::{deserialize_hex_try_from, deserialize_json}, +}; #[derive(Debug, Clone, Identifiable, Queryable)] #[diesel(table_name = stealth_outputs)] @@ -26,7 +29,8 @@ pub struct StealthOutput { pub status: String, pub locked_at: Option, pub locked_by_proof: Option, - pub encryption_secret_key_index: i64, + pub view_only_key_id: String, + pub owner_key_id: Option, pub encrypted_data: Vec, pub tag_byte: i32, pub is_burnt: bool, @@ -64,7 +68,8 @@ impl StealthOutput { ), } })?, - encryption_secret_key_index: self.encryption_secret_key_index as u64, + view_only_key_id: deserialize_json(&self.view_only_key_id)?, + owner_key_id: self.owner_key_id.as_ref().map(deserialize_json).transpose()?, encrypted_data: EncryptedData::try_from(self.encrypted_data).map_err(|len| { WalletStorageError::DecodingError { operation: "try_into_output", diff --git a/crates/wallet/storage_sqlite/src/models/transaction.rs b/crates/wallet/storage_sqlite/src/models/transaction.rs index 71f6e92d62..2e4aaf0956 100644 --- a/crates/wallet/storage_sqlite/src/models/transaction.rs +++ b/crates/wallet/storage_sqlite/src/models/transaction.rs @@ -43,7 +43,7 @@ pub struct TransactionRecord { impl TransactionRecord { pub fn try_into_wallet_transaction(self) -> Result { - let transaction = deserialize_json::(&self.transaction_json)?; + let transaction = deserialize_json::(&self.transaction_json)?; let is_dry_run = transaction.is_dry_run(); Ok(WalletTransaction { diff --git a/crates/wallet/storage_sqlite/src/models/utxo_process_queue.rs b/crates/wallet/storage_sqlite/src/models/utxo_process_queue.rs index 3921df661d..6733313261 100644 --- a/crates/wallet/storage_sqlite/src/models/utxo_process_queue.rs +++ b/crates/wallet/storage_sqlite/src/models/utxo_process_queue.rs @@ -6,7 +6,7 @@ use diesel::Queryable; #[derive(Debug, Clone, Queryable)] pub struct UtxoProcessQueue { pub _id: i32, - pub account_key_index: i64, + pub _account_id: i32, pub resource_address: String, pub utxo_tag: i32, pub public_nonce: String, diff --git a/crates/wallet/storage_sqlite/src/models/vault.rs b/crates/wallet/storage_sqlite/src/models/vault.rs index a9e57b7361..35054b3cfa 100644 --- a/crates/wallet/storage_sqlite/src/models/vault.rs +++ b/crates/wallet/storage_sqlite/src/models/vault.rs @@ -7,8 +7,7 @@ use diesel::{Identifiable, Queryable}; use tari_ootle_wallet_sdk::storage::WalletStorageError; use tari_template_lib::{ models::{ComponentAddress, ResourceAddress, VaultId}, - prelude::ResourceType, - types::Amount, + types::{Amount, ResourceType}, }; use time::PrimitiveDateTime; diff --git a/crates/wallet/storage_sqlite/src/reader.rs b/crates/wallet/storage_sqlite/src/reader.rs index 8ea57e6b7c..d64e84085a 100644 --- a/crates/wallet/storage_sqlite/src/reader.rs +++ b/crates/wallet/storage_sqlite/src/reader.rs @@ -34,6 +34,7 @@ use tari_ootle_wallet_sdk::{ AuthoredTemplateModel, ConfidentialOutputModel, Config, + KeyType, NonFungibleToken, OutputStatus, ResourceModel, @@ -50,8 +51,7 @@ use tari_ootle_wallet_sdk::{ }; use tari_template_lib::{ models::{ResourceAddress, VaultId}, - prelude::{ComponentAddress, NonFungibleId, PedersenCommitmentBytes, RistrettoPublicKeyBytes}, - resource::ResourceType, + prelude::{ComponentAddress, NonFungibleId, PedersenCommitmentBytes, ResourceType, RistrettoPublicKeyBytes}, types::{crypto::UtxoTag, TemplateAddress}, }; use tari_transaction::TransactionId; @@ -160,6 +160,37 @@ impl WalletStoreReader for ReadTransaction<'_> { }) } + fn key_manager_get_raw_imported_key(&mut self, id: u64) -> Result<(KeyType, Box<[u8]>), WalletStorageError> { + const OPERATION: &str = "key_manager_get_raw_imported_key"; + use crate::schema::key_manager_imported_keys; + + let (key_type, data) = key_manager_imported_keys::table + .select(( + key_manager_imported_keys::key_type, + key_manager_imported_keys::encrypted_secret, + )) + .filter(key_manager_imported_keys::id.eq(id as i32)) + .first::<(String, Vec)>(self.connection()) + .optional() + .map_err(|e| WalletStorageError::general(OPERATION, e))? + .ok_or_else(|| WalletStorageError::NotFound { + operation: OPERATION, + entity: "imported_key".to_string(), + key: id.to_string(), + })?; + + Ok(( + key_type + .parse::() + .map_err(|_| WalletStorageError::DecodingError { + operation: OPERATION, + item: "imported_key", + details: format!("Failed to parse key type: {}", key_type), + })?, + data.into_boxed_slice(), + )) + } + // -------------------------------- Config -------------------------------- // fn config_get(&mut self, key: &str) -> Result, WalletStorageError> { let config = self.config_get_string(key)?; @@ -638,7 +669,7 @@ impl WalletStoreReader for ReadTransaction<'_> { } // -------------------------------- Outputs -------------------------------- // - fn outputs_get_unspent_balance(&mut self, vault_address: &VaultId) -> Result { + fn confidential_outputs_get_unspent_balance(&mut self, vault_address: &VaultId) -> Result { use crate::schema::{confidential_outputs, vaults}; let vault_id = vaults::table @@ -663,7 +694,7 @@ impl WalletStoreReader for ReadTransaction<'_> { Ok(balance.map(|v| v.to_u64().expect("overflow")).unwrap_or(0)) } - fn outputs_get_locked_by_lock_id( + fn confidential_outputs_get_locked_by_lock_id( &mut self, lock_id: WalletLockId, ) -> Result, WalletStorageError> { @@ -715,7 +746,7 @@ impl WalletStoreReader for ReadTransaction<'_> { Ok(confidential_outputs) } - fn outputs_get_by_commitment( + fn confidential_outputs_get_by_commitment( &mut self, vault_id: &VaultId, commitment: &PedersenCommitmentBytes, @@ -757,7 +788,7 @@ impl WalletStoreReader for ReadTransaction<'_> { Ok(output) } - fn outputs_get_by_account_and_status( + fn confidential_outputs_get_by_account_and_status( &mut self, account_addr: &ComponentAddress, status: OutputStatus, @@ -1248,18 +1279,20 @@ impl WalletStoreReader for ReadTransaction<'_> { fn utxo_process_queue_fetch_batch( &mut self, batch_size: usize, - ) -> Result>, WalletStorageError> { + ) -> Result>, WalletStorageError> { const OPERATION: &str = "utxo_process_queue_fetch_batch"; - use crate::schema::utxo_process_queue; + use crate::schema::{accounts, utxo_process_queue}; let rows = utxo_process_queue::table + .inner_join(accounts::table.on(accounts::id.eq(utxo_process_queue::account_id))) + .select((utxo_process_queue::all_columns, accounts::address.assume_not_null())) .order(utxo_process_queue::id.asc()) .limit(i64::try_from(batch_size).unwrap_or(i64::MAX)) - .get_results::(self.connection()) + .get_results::<(models::UtxoProcessQueue, String)>(self.connection()) .map_err(|e| WalletStorageError::general(OPERATION, e))?; let mut result = HashMap::new(); - for row in &rows { + for (row, account_addr) in &rows { let resource_address = ResourceAddress::from_str(&row.resource_address).map_err(|e| WalletStorageError::DecodingError { operation: OPERATION, @@ -1268,10 +1301,15 @@ impl WalletStoreReader for ReadTransaction<'_> { })?; let tag = UtxoTag::new(row.utxo_tag as u32); let public_nonce = deserialize_hex_try_from(&row.public_nonce)?; + let account_addr = account_addr.parse().map_err(|e| WalletStorageError::DecodingError { + operation: OPERATION, + item: "account_address", + details: format!("Corrupt db: invalid account address '{}': {}", account_addr, e), + })?; result .entry(resource_address) .or_insert_with(HashMap::new) - .insert((tag, public_nonce), row.account_key_index as u64); + .insert((tag, public_nonce), account_addr); } Ok(result) diff --git a/crates/wallet/storage_sqlite/src/schema.rs b/crates/wallet/storage_sqlite/src/schema.rs index cfb32d6346..5ca4d093d1 100644 --- a/crates/wallet/storage_sqlite/src/schema.rs +++ b/crates/wallet/storage_sqlite/src/schema.rs @@ -5,7 +5,9 @@ diesel::table! { id -> Integer, name -> Nullable, address -> Text, - owner_key_index -> BigInt, + owner_public_key -> Text, + view_only_key_id -> Text, + owner_key_id -> Nullable, is_default -> Bool, is_confirmed_on_chain -> Bool, stealth_resources -> Text, @@ -45,7 +47,8 @@ diesel::table! { commitment -> Text, value -> BigInt, sender_public_nonce -> Nullable, - encryption_secret_key_index -> BigInt, + view_only_key_id -> Text, + owner_key_id -> Nullable, public_asset_tag -> Nullable, status -> Text, locked_at -> Nullable, @@ -67,6 +70,16 @@ diesel::table! { } } +diesel::table! { + key_manager_imported_keys (id) { + id -> Integer, + label -> Text, + encrypted_secret -> Binary, + key_type -> Text, + created_at -> Timestamp, + } +} + diesel::table! { key_manager_states (id) { id -> Integer, @@ -142,7 +155,8 @@ diesel::table! { status -> Text, locked_at -> Nullable, lock_id -> Nullable, - encryption_secret_key_index -> BigInt, + view_only_key_id -> Text, + owner_key_id -> Nullable, encrypted_data -> Binary, tag_byte -> Integer, is_burnt -> Bool, @@ -190,7 +204,7 @@ diesel::table! { diesel::table! { utxo_process_queue (id) { id -> Integer, - account_key_index -> BigInt, + account_id -> Integer, resource_address -> Text, utxo_tag -> Integer, public_nonce -> Text, @@ -250,6 +264,7 @@ diesel::allow_tables_to_appear_in_same_query!( authored_templates, confidential_outputs, config, + key_manager_imported_keys, key_manager_states, locks, non_fungible_tokens, diff --git a/crates/wallet/storage_sqlite/src/serialization.rs b/crates/wallet/storage_sqlite/src/serialization.rs index 3c3c179129..406549238c 100644 --- a/crates/wallet/storage_sqlite/src/serialization.rs +++ b/crates/wallet/storage_sqlite/src/serialization.rs @@ -14,8 +14,8 @@ pub fn serialize_json(t: &T) -> Result(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| WalletStorageError::DecodingError { +pub fn deserialize_json>(s: S) -> Result { + serde_json::from_str(s.as_ref()).map_err(|e| WalletStorageError::DecodingError { operation: "deserialize_json", item: type_name::(), details: e.to_string(), diff --git a/crates/wallet/storage_sqlite/src/writer.rs b/crates/wallet/storage_sqlite/src/writer.rs index efb9c74ec6..7056523d6b 100644 --- a/crates/wallet/storage_sqlite/src/writer.rs +++ b/crates/wallet/storage_sqlite/src/writer.rs @@ -28,6 +28,9 @@ use tari_ootle_wallet_sdk::{ AccountUpdate, AuthoredTemplateModel, ConfidentialOutputModel, + ImportedKeyId, + KeyId, + KeyType, NewAccountData, NonFungibleToken, OutputStatus, @@ -60,7 +63,7 @@ use crate::{ models::StealthOutputUpdate, reader::ReadTransaction, schema::accounts, - serialization::{serialize_hex, serialize_json}, + serialization::{deserialize_json, serialize_hex, serialize_json}, }; const LOG_TARGET: &str = "auth::tari::dan::wallet_sdk::storage_sqlite::writer"; @@ -274,6 +277,30 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } + fn key_manager_insert_imported_key( + &mut self, + label: &str, + encrypted_key: &[u8], + key_type: KeyType, + ) -> Result { + const OPERATION: &str = "key_manager_insert_imported_key"; + use crate::schema::key_manager_imported_keys; + + diesel::insert_into(key_manager_imported_keys::table) + .values(( + key_manager_imported_keys::label.eq(label), + key_manager_imported_keys::encrypted_secret.eq(encrypted_key), + key_manager_imported_keys::key_type.eq(key_type.to_string()), + )) + .execute(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + let last_inserted_id: i32 = diesel::select(dsl::sql::("last_insert_rowid()")) + .get_result(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + Ok(ImportedKeyId::from(last_inserted_id as u32)) + } + // -------------------------------- Config -------------------------------- // fn config_set( @@ -496,7 +523,10 @@ impl WalletStoreWriter for WriteTransaction<'_> { &mut self, account_name: Option<&str>, address: &ComponentAddress, - owner_key_index: u64, + view_only_key_id: KeyId, + owner_key_id: Option, + owner_public_key: &RistrettoPublicKeyBytes, + associated_stealth_resources: &HashSet, is_confirmed_on_chain: bool, is_default: bool, ) -> Result<(), WalletStorageError> { @@ -513,7 +543,10 @@ impl WalletStoreWriter for WriteTransaction<'_> { .values(( accounts::name.eq(account_name), accounts::address.eq(address.to_string()), - accounts::owner_key_index.eq(owner_key_index as i64), + accounts::view_only_key_id.eq(serialize_json(&view_only_key_id)?), + accounts::owner_key_id.eq(owner_key_id.as_ref().map(serialize_json).transpose()?), + accounts::owner_public_key.eq(serialize_hex(owner_public_key)), + accounts::stealth_resources.eq(serialize_json(&associated_stealth_resources)?), accounts::is_confirmed_on_chain.eq(is_confirmed_on_chain), accounts::is_default.eq(is_default), )) @@ -818,9 +851,9 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } - // -------------------------------- Outputs -------------------------------- // + // -------------------------------- Confidential Outputs -------------------------------- // - fn outputs_lock_smallest_amount( + fn confidential_outputs_lock_smallest_amount( &mut self, vault_id: &VaultId, lock_id: WalletLockId, @@ -836,6 +869,8 @@ impl WalletStoreWriter for WriteTransaction<'_> { let locked_output = confidential_outputs::table .filter(confidential_outputs::vault_id.eq(vault_db_id)) .filter(confidential_outputs::status.eq(OutputStatus::Unspent.as_key_str())) + // We have the key to spend + .filter(confidential_outputs::owner_key_id.is_not_null()) .order_by(confidential_outputs::value.asc()) .first::(self.connection()) .optional() @@ -891,7 +926,8 @@ impl WalletStoreWriter for WriteTransaction<'_> { }) }) .transpose()?, - encryption_secret_key_index: locked_output.encryption_secret_key_index as u64, + view_only_key_id: deserialize_json(locked_output.view_only_key_id)?, + owner_key_id: locked_output.owner_key_id.as_ref().map(deserialize_json).transpose()?, encrypted_data: EncryptedData::try_from(locked_output.encrypted_data).map_err(|len| { WalletStorageError::DecodingError { operation: "outputs_lock_smallest_amount", @@ -905,7 +941,7 @@ impl WalletStoreWriter for WriteTransaction<'_> { }) } - fn outputs_insert(&mut self, output: ConfidentialOutputModel) -> Result<(), WalletStorageError> { + fn confidential_outputs_insert(&mut self, output: ConfidentialOutputModel) -> Result<(), WalletStorageError> { use crate::schema::{accounts, confidential_outputs, vaults}; let account_id = accounts::table @@ -928,7 +964,8 @@ impl WalletStoreWriter for WriteTransaction<'_> { // TODO: allow arbitrary precision in wallet confidential_outputs::value.eq(output.value.to_u64_checked().expect("value overflow u64") as i64), confidential_outputs::sender_public_nonce.eq(output.sender_public_nonce.map(|pk| pk.to_hex())), - confidential_outputs::encryption_secret_key_index.eq(output.encryption_secret_key_index as i64), + confidential_outputs::view_only_key_id.eq(serialize_json(&output.view_only_key_id)?), + confidential_outputs::owner_key_id.eq(output.owner_key_id.as_ref().map(serialize_json).transpose()?), confidential_outputs::encrypted_data.eq(output.encrypted_data.as_ref()), confidential_outputs::status.eq(output.status.as_key_str()), confidential_outputs::lock_id.eq(output.lock_id), @@ -939,7 +976,7 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } - fn outputs_finalize_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError> { + fn confidential_outputs_finalize_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError> { use crate::schema::confidential_outputs; // Unlock locked unconfirmed confidential_outputs @@ -969,7 +1006,7 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } - fn outputs_release_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError> { + fn confidential_outputs_release_by_lock_id(&mut self, lock_id: WalletLockId) -> Result<(), WalletStorageError> { use crate::schema::confidential_outputs; // Unlock locked unspent confidential_outputs @@ -1022,6 +1059,8 @@ impl WalletStoreWriter for WriteTransaction<'_> { .eq(OutputStatus::LockedUnconfirmed.as_key_str()) .and(stealth_outputs::lock_id.eq(lock_id ))), ) + // We have the key to spend + .filter(stealth_outputs::owner_key_id.is_not_null()) .filter(stealth_outputs::is_burnt.eq(false)) .filter(stealth_outputs::is_frozen.eq(false)) .order_by(stealth_outputs::value.asc()) @@ -1067,7 +1106,8 @@ impl WalletStoreWriter for WriteTransaction<'_> { stealth_outputs::commitment.eq(output.commitment.to_hex()), stealth_outputs::value.eq(output.value.to_string()), stealth_outputs::sender_public_nonce.eq(serialize_hex(output.sender_public_nonce)), - stealth_outputs::encryption_secret_key_index.eq(output.encryption_secret_key_index as i64), + stealth_outputs::view_only_key_id.eq(serialize_json(&output.view_only_key_id)?), + stealth_outputs::owner_key_id.eq(output.owner_key_id.as_ref().map(serialize_json).transpose()?), stealth_outputs::encrypted_data.eq(output.encrypted_data.as_ref()), stealth_outputs::tag_byte.eq(output.tag_byte.value() as i32), stealth_outputs::is_on_chain.eq(output.is_on_chain), @@ -1427,18 +1467,23 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(()) } - fn utxo_process_queue_extend>( + fn utxo_process_queue_extend>( &mut self, resource_address: &ResourceAddress, items: I, ) -> Result<(), WalletStorageError> { const OPERATION: &str = "utxo_process_queue_extend"; - use crate::schema::utxo_process_queue; + use crate::schema::{accounts, utxo_process_queue}; - for (account_key_index, unspent) in items { + for (account_address, unspent) in items { diesel::insert_into(utxo_process_queue::table) .values(( - utxo_process_queue::account_key_index.eq(account_key_index as i64), + utxo_process_queue::account_id.eq(accounts::table + .select(accounts::id) + .filter(accounts::address.eq(account_address.to_string())) + .limit(1) + .single_value() + .assume_not_null()), utxo_process_queue::utxo_tag.eq(unspent.tag.value() as i32), utxo_process_queue::public_nonce.eq(serialize_hex(unspent.public_nonce)), utxo_process_queue::resource_address.eq(resource_address.to_string()), diff --git a/crates/wallet/storage_sqlite/tests/accounts.rs b/crates/wallet/storage_sqlite/tests/accounts.rs index a78ffa6ea8..4534867d58 100644 --- a/crates/wallet/storage_sqlite/tests/accounts.rs +++ b/crates/wallet/storage_sqlite/tests/accounts.rs @@ -4,11 +4,11 @@ use std::str::FromStr; use tari_ootle_wallet_sdk::{ - models::AccountUpdate, + models::{AccountUpdate, KeyId}, storage::{WalletStore, WalletStoreReader, WalletStoreWriter}, }; use tari_ootle_wallet_storage_sqlite::SqliteWalletStore; -use tari_template_lib::models::ComponentAddress; +use tari_template_lib::{models::ComponentAddress, prelude::RistrettoPublicKeyBytes}; #[test] fn update_account() { @@ -18,7 +18,17 @@ fn update_account() { ComponentAddress::from_str("component_91bef6af37bfb39b20260275c37a9e8acfc0517127284cd8f05944c8ffffffff") .unwrap(); let mut tx = db.create_write_tx().unwrap(); - tx.accounts_insert(Some("test"), &address, 0, false, false).unwrap(); + tx.accounts_insert( + Some("test"), + &address, + KeyId::derived(0), + Some(KeyId::derived(0)), + &RistrettoPublicKeyBytes::default(), + &Default::default(), + false, + false, + ) + .unwrap(); tx.accounts_update(&address, AccountUpdate { name: Some("foo"), ..Default::default() diff --git a/integration_tests/src/wallet_daemon_client.rs b/integration_tests/src/wallet_daemon_client.rs index 42c55ecc97..6c7289969f 100644 --- a/integration_tests/src/wallet_daemon_client.rs +++ b/integration_tests/src/wallet_daemon_client.rs @@ -206,7 +206,7 @@ pub async fn create_account( let request = AccountsCreateRequest { account_name: Some(account_name.clone()), is_default: None, - key_id: None, + key_index: None, }; let resp = timeout(Duration::from_secs(5), client.create_account(request)) @@ -240,7 +240,10 @@ pub async fn create_account_with_free_coins>( .create_account(AccountsCreateRequest { account_name: Some(account_name.clone()), is_default: None, - key_id: account.as_ref().map(|a| a.account.key_index), + key_index: account + .as_ref() + .and_then(|a| a.account.owner_key_id) + .and_then(|k| k.derived_index()), }) .await .unwrap(); @@ -442,7 +445,7 @@ pub async fn submit_manifest_with_signing_keys( let transaction_submit_req = TransactionSubmitRequest { transaction, - signing_key_index: Some(account.key_index), + signing_key_id: account.owner_key_id, detect_inputs: true, detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -527,7 +530,7 @@ pub async fn submit_manifest( let transaction_submit_req = TransactionSubmitRequest { transaction, - signing_key_index: Some(account.key_index), + signing_key_id: account.owner_key_id, detect_inputs: true, detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -642,7 +645,7 @@ pub async fn create_component( let transaction_submit_req = TransactionSubmitRequest { transaction, - signing_key_index: Some(account.key_index), + signing_key_id: account.owner_key_id, detect_inputs: true, detect_inputs_use_unversioned: true, proof_ids: vec![], @@ -931,7 +934,7 @@ async fn submit_unsigned_tx_and_wait_for_response( ); let submit_req = TransactionSubmitRequest { transaction, - signing_key_index: Some(account.key_index), + signing_key_id: account.owner_key_id, detect_inputs: true, detect_inputs_use_unversioned: use_unversioned_inputs, proof_ids: vec![], diff --git a/integration_tests/tests/steps/wallet_daemon.rs b/integration_tests/tests/steps/wallet_daemon.rs index 01bda572e2..f5cb9244b3 100644 --- a/integration_tests/tests/steps/wallet_daemon.rs +++ b/integration_tests/tests/steps/wallet_daemon.rs @@ -127,7 +127,7 @@ async fn when_i_run_up_fees(world: &mut TariWorld, amount: u64, wallet_daemon_na let transaction_submit_req = TransactionSubmitRequest { transaction, - signing_key_index: Some(account.key_index()), + signing_key_id: account.owner_key_id(), detect_inputs: true, detect_inputs_use_unversioned: true, proof_ids: vec![], diff --git a/utilities/tariswap_test_bench/src/accounts.rs b/utilities/tariswap_test_bench/src/accounts.rs index aaf03a42fb..064df4ce25 100644 --- a/utilities/tariswap_test_bench/src/accounts.rs +++ b/utilities/tariswap_test_bench/src/accounts.rs @@ -11,11 +11,11 @@ use tari_engine_types::{ ToByteType, }; use tari_ootle_common_types::SubstateRequirement; -use tari_ootle_wallet_sdk::models::Account; +use tari_ootle_wallet_sdk::models::{Account, KeyId}; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::{ constants::{XTR, XTR_FAUCET_COMPONENT_ADDRESS, XTR_FAUCET_VAULT_ADDRESS}, - resource::ResourceType, + prelude::ResourceType, }; use tari_transaction::args; @@ -55,7 +55,9 @@ impl Runner { .find(|vault_id| *vault_id != XTR_FAUCET_VAULT_ADDRESS) .unwrap(); - self.sdk.accounts_api().add_account(None, &account, 0, true, true)?; + self.sdk + .accounts_api() + .add_account(None, &account, KeyId::derived(0), KeyId::derived(0), true, true)?; self.sdk .accounts_api() .add_vault(account, vault, XTR, ResourceType::Stealth, Some("XTR".to_string()), 6)?; @@ -117,9 +119,14 @@ impl Runner { }) .expect("New account not found in diff"); - self.sdk - .accounts_api() - .add_account(None, &account_addr, owner.key_index, true, false)?; + self.sdk.accounts_api().add_account( + None, + &account_addr, + owner.as_key_id(), + owner.as_key_id(), + true, + false, + )?; let account = self.sdk.accounts_api().get_account_by_address(&account_addr)?; accounts.push(account.account); } diff --git a/utilities/tariswap_test_bench/src/runner.rs b/utilities/tariswap_test_bench/src/runner.rs index 3837dc9aef..ae0375bdba 100644 --- a/utilities/tariswap_test_bench/src/runner.rs +++ b/utilities/tariswap_test_bench/src/runner.rs @@ -7,7 +7,7 @@ use log::info; use tari_crypto::tari_utilities::SafePassword; use tari_engine_types::commit_result::FinalizeResult; use tari_ootle_common_types::Network; -use tari_ootle_wallet_sdk::{WalletSdk as Sdk, WalletSdkConfig}; +use tari_ootle_wallet_sdk::{cipher_seed::CipherSeedRestore, WalletSdk as Sdk, WalletSdkConfig}; use tari_ootle_wallet_sdk_services::indexer_jrpc::IndexerJsonRpcNetworkInterface; use tari_ootle_wallet_storage_sqlite::SqliteWalletStore; use tari_transaction::{Transaction, TransactionBuilder, TransactionId}; @@ -121,6 +121,6 @@ fn initialize_wallet_sdk>(db_path: P, indexer_url: Url) -> Result }; let indexer = IndexerJsonRpcNetworkInterface::new(indexer_url); let mut sdk = WalletSdk::initialize(store, indexer, sdk_config)?; - sdk.initialize_cipher_seed(None)?; + sdk.initialize_cipher_seed(CipherSeedRestore::CreateNewIfRequired)?; Ok(sdk) } diff --git a/utilities/tariswap_test_bench/src/tariswap.rs b/utilities/tariswap_test_bench/src/tariswap.rs index 7eb4b679e7..d8f1ff08c7 100644 --- a/utilities/tariswap_test_bench/src/tariswap.rs +++ b/utilities/tariswap_test_bench/src/tariswap.rs @@ -4,7 +4,6 @@ use std::collections::HashMap; use log::info; -use tari_crypto::{keys::PublicKey, ristretto::RistrettoPublicKey}; use tari_engine_types::{indexed_value::decode_value_at_path, ToByteType}; use tari_ootle_common_types::{optional::Optional, SubstateRequirement}; use tari_ootle_wallet_sdk::models::Account; @@ -95,9 +94,9 @@ impl Runner { let primary_account_key = self .sdk .key_manager_api() - .derive_account_key(primary_account.key_index)?; + .get_account_owner_key(primary_account.owner_key_id.expect("no owner key id"))?; let mut tx_ids = Vec::with_capacity(200); - let primary_account_pk = RistrettoPublicKey::from_secret_key(&primary_account_key.key).to_byte_type(); + let primary_account_pk = primary_account_key.to_public_key().to_byte_type(); for i in 0..5 { let _timer = TraceTimer::info("tariswap", "add_liquidity") @@ -105,7 +104,10 @@ impl Runner { for (i, tariswap) in tariswaps.iter().enumerate().skip(i * 200).take(200) { let account = &accounts[i % accounts.len()]; - let key = self.sdk.key_manager_api().derive_account_key(account.key_index)?; + let key = self + .sdk + .key_manager_api() + .get_account_owner_key(account.owner_key_id.expect("no owner key id"))?; let xtr_vault = self .sdk .accounts_api() @@ -148,8 +150,8 @@ impl Runner { .put_last_instruction_output_on_workspace("lp") .call_method(account.component_address, "deposit", args![Workspace("lp")]) .with_authorized_seal_signer() - .add_signer(&primary_account_pk, &key.key) - .build_and_seal(&primary_account_key.key); + .add_signer(&primary_account_pk, key.secret()) + .build_and_seal(primary_account_key.secret()); assert!( transaction.verify_all_signatures(), @@ -210,15 +212,18 @@ impl Runner { let primary_account_key = self .sdk .key_manager_api() - .derive_account_key(primary_account.key_index)?; - let primary_account_pk = RistrettoPublicKey::from_secret_key(&primary_account_key.key).to_byte_type(); + .get_account_owner_key(primary_account.owner_key_id.expect("no owner key id"))?; + let primary_account_pk = primary_account_key.to_public_key().to_byte_type(); let mut tx_ids = vec![]; // Swap XTR for faucet for i in 0..5 { for (i, account) in accounts.iter().enumerate().skip(i * 200).take(200) { let tariswap = &tariswaps[i % tariswaps.len()]; - let key = self.sdk.key_manager_api().derive_account_key(account.key_index)?; + let key = self + .sdk + .key_manager_api() + .get_account_owner_key(account.owner_key_id.expect("no owner key id"))?; let xtr_vault = self .sdk .accounts_api() @@ -264,8 +269,8 @@ impl Runner { .put_last_instruction_output_on_workspace("swapped") .call_method(account.component_address, "deposit", args![Workspace("swapped")]) .with_authorized_seal_signer() - .add_signer(&primary_account_pk, &key.key) - .build_and_seal(&primary_account_key.key); + .add_signer(&primary_account_pk, key.secret()) + .build_and_seal(primary_account_key.secret()); tx_ids.push(self.submit_transaction(transaction).await?); } @@ -289,7 +294,10 @@ impl Runner { // Swap faucet for XTR for i in 0..5 { for (i, account) in accounts.iter().enumerate().skip(i * 200).take(200) { - let key = self.sdk.key_manager_api().derive_account_key(account.key_index)?; + let key = self + .sdk + .key_manager_api() + .get_account_owner_key(account.owner_key_id.expect("no owner key id"))?; let xtr_vault = self .sdk .accounts_api() @@ -329,7 +337,7 @@ impl Runner { .call_method(tariswap.component_address, "swap", args![Workspace("b"), XTR,]) .put_last_instruction_output_on_workspace("swapped") .call_method(account.component_address, "deposit", args![Workspace("swapped")]) - .build_and_seal(&key.key); + .build_and_seal(key.secret()); tx_ids.push(self.submit_transaction(transaction).await?); } From f8d6ef7f1a0911c6f714d3da90ece0e6883ad987 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 6 Oct 2025 10:06:16 +0400 Subject: [PATCH 2/7] review comments --- .../src/process_manager/instances/manager.rs | 2 +- applications/tari_walletd/src/main.rs | 4 +++- applications/tari_walletd/web_ui/src/main.tsx | 1 - .../src/routes/Wallet/Components/Keys.tsx | 11 +++++++---- .../src/services/api/hooks/useAccounts.ts | 2 +- bindings/src/helpers/enum.ts | 11 +---------- .../wallet_daemon_client/src/index.ts | 1 + .../wallet_daemon_client/src}/serialize.ts | 0 .../template_lib_types/src/amount/amount.rs | 19 +++++++++++++++++++ crates/wallet/crypto/src/encryption.rs | 2 +- crates/wallet/sdk/src/apis/accounts.rs | 2 +- crates/wallet/sdk/src/apis/stealth_outputs.rs | 2 +- crates/wallet/sdk/src/models/key.rs | 9 +-------- .../2023-02-08-122514_initial/up.sql | 2 +- .../src/models/confidential_output.rs | 16 ++++++++++++++-- 15 files changed, 52 insertions(+), 32 deletions(-) rename {applications/tari_walletd/web_ui/src/utils => clients/javascript/wallet_daemon_client/src}/serialize.ts (100%) diff --git a/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs b/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs index 96b14b6e42..41a6b3e44e 100644 --- a/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs +++ b/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs @@ -185,7 +185,7 @@ impl InstanceManager { let claim_public_key = serde_json::from_reader::<_, serde_json::Value>(reader) .context("Failed to read claim public key file")?; let claim_public_key = claim_public_key - .get("public_key") + .get("account_public_key") .and_then(|pk| pk.as_str()) .context("Failed to extract public key from claim public key file")?; info!("Setting claim public key to {}", claim_public_key); diff --git a/applications/tari_walletd/src/main.rs b/applications/tari_walletd/src/main.rs index 52d0dc2511..d30754de39 100644 --- a/applications/tari_walletd/src/main.rs +++ b/applications/tari_walletd/src/main.rs @@ -29,6 +29,7 @@ use tari_common::initialize_logging; use tari_crypto::tari_utilities::ByteArray; use tari_engine_types::ToByteType; use tari_ootle_app_utilities::configuration::load_configuration; +use tari_ootle_common_types::optional::Optional; use tari_ootle_wallet_sdk::{apis::key_manager::KeyBranch, cipher_seed::CipherSeedRestore}; use tari_ootle_walletd::{ cli::{Cli, Subcommand}, @@ -90,13 +91,14 @@ async fn main() -> Result<(), anyhow::Error> { let public_key = account_address.address.account_key().to_byte_type(); let view_only_public_key = account_address.address.view_only_key().to_byte_type(); let account_addr = sdk.accounts_api().derive_account_address_from_public_key(&public_key); + let is_default = !sdk.accounts_api().any_accounts_exist()?; sdk.accounts_api().add_account( name.as_deref(), &account_addr, account_address.view_only_key_id, account_address.owner_key_id, false, - true, + is_default, )?; if *set_active { diff --git a/applications/tari_walletd/web_ui/src/main.tsx b/applications/tari_walletd/web_ui/src/main.tsx index 4f6a0dec48..e5ddc5775b 100644 --- a/applications/tari_walletd/web_ui/src/main.tsx +++ b/applications/tari_walletd/web_ui/src/main.tsx @@ -29,7 +29,6 @@ import TransactionDetails from "@routes/Transactions/TransactionDetails"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import queryClient from "@api/queryClient"; -import "@utils/serialize"; const router = createBrowserRouter([ { diff --git a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx index 9c1d512ca1..b2b115da0b 100644 --- a/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx +++ b/applications/tari_walletd/web_ui/src/routes/Wallet/Components/Keys.tsx @@ -37,13 +37,16 @@ import { DataTableCell } from "@components/StyledComponents"; import FetchStatusCheck from "@components/FetchStatusCheck"; import { KeyId } from "@tari-project/typescript-bindings"; -function Key([key, pk, active]: [KeyId, string, boolean], setActive: (key_id: KeyId) => void) { +function Key([keyId, pk, active]: [KeyId, string, boolean], setActive: (key_id: KeyId) => void) { + const rowKey = + "Derived" in keyId + ? `derived-${keyId.Derived.index.toString()}` + : `imported-${keyId.Imported.local_key_id.toString()}`; return ( - {/* @ts-ignore */} - {key.Derived.index} + {rowKey} {pk} - {active ? Active :
setActive(key)}>Activate
}
+ {active ? Active :
setActive(keyId)}>Activate
}
); } diff --git a/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts b/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts index 9e5ad4dfa2..92b3763e8b 100644 --- a/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts +++ b/applications/tari_walletd/web_ui/src/services/api/hooks/useAccounts.ts @@ -78,7 +78,7 @@ export const useAccountsCreate = () => { return await accountsCreate({ account_name: req.accountName || "", is_default: req.isDefault || null, - key_index: req.keyId || null, + key_index: req.keyId ?? null, }); }, onError: (error: ApiError) => { diff --git a/bindings/src/helpers/enum.ts b/bindings/src/helpers/enum.ts index 8226f75b99..bbc56a14f7 100644 --- a/bindings/src/helpers/enum.ts +++ b/bindings/src/helpers/enum.ts @@ -22,14 +22,5 @@ export function matchesTypeEnum(enumObject: T | null, value: T const enumValue = (enumObject as any)[key]; const valueValue = (value as any)[key]; - // Check for primitive types - if (typeof enumValue === "string" || typeof enumValue === "number" || typeof enumValue === "boolean") { - return typeof enumValue === typeof valueValue; - } - - // Check for object types (shallow check) - if (typeof enumValue === "object" && enumValue !== null) { - return enumValue === valueValue; - } - return false; + return enumValue === valueValue; } diff --git a/clients/javascript/wallet_daemon_client/src/index.ts b/clients/javascript/wallet_daemon_client/src/index.ts index 760ef633c0..28e7a71989 100644 --- a/clients/javascript/wallet_daemon_client/src/index.ts +++ b/clients/javascript/wallet_daemon_client/src/index.ts @@ -3,6 +3,7 @@ * // SPDX-License-Identifier: BSD-3-Clause */ +import "./serialize"; import type { AccountGetDefaultRequest, AccountGetRequest, diff --git a/applications/tari_walletd/web_ui/src/utils/serialize.ts b/clients/javascript/wallet_daemon_client/src/serialize.ts similarity index 100% rename from applications/tari_walletd/web_ui/src/utils/serialize.ts rename to clients/javascript/wallet_daemon_client/src/serialize.ts diff --git a/crates/template_lib_types/src/amount/amount.rs b/crates/template_lib_types/src/amount/amount.rs index 8a0926a167..6b42a9585e 100644 --- a/crates/template_lib_types/src/amount/amount.rs +++ b/crates/template_lib_types/src/amount/amount.rs @@ -3,6 +3,7 @@ use bnum::BUint; use newtype_ops::newtype_ops; +use serde::ser::Error; use tari_template_abi::rust::{cmp, fmt, fmt::Debug, iter::Sum, ops::Neg, str::FromStr, write}; use crate::{impl_from, partial_eq_impl, partial_ord_impl}; @@ -314,6 +315,10 @@ impl Amount { } } + /// Formats the amount as a decimal string with the specified number of decimal places. + /// + /// ## Panics + /// Panics if `decimals` is greater than 57. pub fn to_decimal_string(&self, decimals: u32) -> String { let mut s = String::new(); self.fmt_decimals(&mut s, decimals) @@ -327,6 +332,11 @@ impl Amount { return Ok(()); } + // I192 can represent up to ~10^57, so 57 decimal places is a safe upper bound + if decimals > 57 { + return Err(fmt::Error::custom("Too many decimal places")); + } + let ten = I192::from(10); let divisor = ten.pow(decimals); let integer_part = self.inner_value().div(divisor); @@ -624,6 +634,15 @@ mod tests { assert_eq!(a.to_decimal_string(6), "0.123456"); assert_eq!(a.to_decimal_string(8), "0.00123456"); + assert_eq!( + a.to_decimal_string(57), + "0.000000000000000000000000000000000000000000000000000123456" + ); + + // > 57 decimals errors + let mut s = String::new(); + a.fmt_decimals(&mut s, 58).unwrap_err(); + let b = Amount::from(-123456); assert_eq!(b.to_decimal_string(0), "-123456"); assert_eq!(b.to_decimal_string(2), "-1234.56"); diff --git a/crates/wallet/crypto/src/encryption.rs b/crates/wallet/crypto/src/encryption.rs index 683e713d97..cbcf1221f4 100644 --- a/crates/wallet/crypto/src/encryption.rs +++ b/crates/wallet/crypto/src/encryption.rs @@ -172,7 +172,7 @@ fn derive_keys(passphrase: &[u8], salt: &[u8]) -> Result AccountsApi<'a, TStore, TNetwor component_address: account_component_address, view_only_key_id: account_address.view_only_key_id, owner_key_id: Some(account_address.owner_key_id), - owner_public_key: Default::default(), + owner_public_key: account_public_key, is_confirmed_on_chain: false, is_default, }, diff --git a/crates/wallet/sdk/src/apis/stealth_outputs.rs b/crates/wallet/sdk/src/apis/stealth_outputs.rs index ef7f9857d2..63e3f5e40a 100644 --- a/crates/wallet/sdk/src/apis/stealth_outputs.rs +++ b/crates/wallet/sdk/src/apis/stealth_outputs.rs @@ -178,7 +178,7 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { pub fn release_locked_outputs(&self, lock_id: WalletLockId) -> Result<(), StealthOutputsApiError> { self.store.with_write_tx(|tx| { - tx.confidential_outputs_release_by_lock_id(lock_id)?; + tx.stealth_outputs_release_by_lock_id(lock_id)?; tx.locks_delete(lock_id)?; Ok(()) }) diff --git a/crates/wallet/sdk/src/models/key.rs b/crates/wallet/sdk/src/models/key.rs index e3dcad4728..b1254d9483 100644 --- a/crates/wallet/sdk/src/models/key.rs +++ b/crates/wallet/sdk/src/models/key.rs @@ -238,19 +238,12 @@ impl KeyId { } } - pub fn imported_view_key_id(&self) -> Option { + pub fn imported_key_id(&self) -> Option { match self { Self::Imported { local_key_id } => Some(*local_key_id), Self::Derived { .. } => None, } } - - pub fn imported_owner_key_id(&self) -> Option { - match self { - Self::Imported { local_key_id, .. } => Some(*local_key_id), - Self::Derived { .. } => None, - } - } } impl Display for KeyId { diff --git a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql index b8d28820cb..496bf89bb5 100644 --- a/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql +++ b/crates/wallet/storage_sqlite/migrations/2023-02-08-122514_initial/up.sql @@ -274,7 +274,7 @@ CREATE INDEX shard_state_versions_account_resource_shard_state_version_idx ON sh CREATE TABLE utxo_process_queue ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - account_id INTEGER NOT NULL, + account_id INTEGER NOT NULL REFERENCES accounts (id) ON DELETE CASCADE, resource_address TEXT NOT NULL, utxo_tag INT NOT NULL, public_nonce TEXT NOT NULL, diff --git a/crates/wallet/storage_sqlite/src/models/confidential_output.rs b/crates/wallet/storage_sqlite/src/models/confidential_output.rs index 861f5c98fa..6d3c2df65f 100644 --- a/crates/wallet/storage_sqlite/src/models/confidential_output.rs +++ b/crates/wallet/storage_sqlite/src/models/confidential_output.rs @@ -60,7 +60,13 @@ impl ConfidentialOutput { value: (self.value as u64).into(), sender_public_nonce: self .sender_public_nonce - .map(|nonce| RistrettoPublicKeyBytes::from_hex(&nonce).unwrap()), + .map(|nonce| RistrettoPublicKeyBytes::from_hex(&nonce)) + .transpose() + .map_err(|_| WalletStorageError::DecodingError { + operation: "try_into_output", + item: "output", + details: "Corrupt db: invalid sender public nonce".to_string(), + })?, view_only_key_id: deserialize_json(&self.view_only_key_id)?, owner_key_id: self.owner_key_id.map(|id| deserialize_json(&id)).transpose()?, encrypted_data: EncryptedData::try_from(self.encrypted_data).map_err(|len| { @@ -72,7 +78,13 @@ impl ConfidentialOutput { })?, public_asset_tag: self .public_asset_tag - .map(|tag| RistrettoPublicKeyBytes::from_hex(&tag).unwrap()), + .map(|tag| RistrettoPublicKeyBytes::from_hex(&tag)) + .transpose() + .map_err(|_| WalletStorageError::DecodingError { + operation: "try_into_output", + item: "output", + details: "Corrupt db: invalid public asset tag".to_string(), + })?, status: self.status.parse().map_err(|_| WalletStorageError::DecodingError { operation: "try_into_output", item: "output", From 8f1bc3562aa1d2eaa0189076e267f17270db896b Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 6 Oct 2025 13:43:30 +0400 Subject: [PATCH 3/7] decouple scanning components --- .../src/process_manager/instances/manager.rs | 6 +- .../tari_walletd/src/handlers/accounts.rs | 15 + applications/tari_walletd/src/main.rs | 1 - crates/engine/src/wasm/module.rs | 3 + crates/engine/src/wasm/process.rs | 8 +- .../engine/tests/templates/buggy/src/lib.rs | 8 +- crates/engine/tests/test.rs | 2 +- crates/wallet/sdk/src/apis/substate.rs | 6 + .../src/account_monitor/handle.rs | 51 +++ .../sdk_services/src/account_monitor/mod.rs | 10 + .../src/account_monitor/monitor.rs | 269 ++++++++++++ .../scanner.rs} | 388 +++--------------- crates/wallet/sdk_services/src/events.rs | 40 +- .../src/transaction_service/service.rs | 5 +- .../sdk_services/src/utxo_scanner/scanner.rs | 5 +- .../src/utxo_scanner/scanner_round.rs | 10 - .../src/utxo_scanner/utxo_recovery.rs | 70 ++-- .../sdk_services/src/utxo_scanner/worker.rs | 9 +- crates/wallet/storage_sqlite/src/writer.rs | 21 +- 19 files changed, 523 insertions(+), 404 deletions(-) create mode 100644 crates/wallet/sdk_services/src/account_monitor/handle.rs create mode 100644 crates/wallet/sdk_services/src/account_monitor/mod.rs create mode 100644 crates/wallet/sdk_services/src/account_monitor/monitor.rs rename crates/wallet/sdk_services/src/{account_monitor.rs => account_monitor/scanner.rs} (67%) diff --git a/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs b/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs index 41a6b3e44e..c58afd16bf 100644 --- a/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs +++ b/applications/tari_swarm_daemon/src/process_manager/instances/manager.rs @@ -182,12 +182,12 @@ impl InstanceManager { .context("Failed to open claim public key file")?; let file = file.into_std().await; let reader = StdBufReader::new(file); - let claim_public_key = serde_json::from_reader::<_, serde_json::Value>(reader) + let claim_data = serde_json::from_reader::<_, serde_json::Value>(reader) .context("Failed to read claim public key file")?; - let claim_public_key = claim_public_key + let claim_public_key = claim_data .get("account_public_key") .and_then(|pk| pk.as_str()) - .context("Failed to extract public key from claim public key file")?; + .ok_or_else(|| anyhow!("Failed to extract public key from claim public key file: {claim_data}"))?; info!("Setting claim public key to {}", claim_public_key); instance_settings.insert("claim_public_key".to_string(), claim_public_key.to_string()); } diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index 458650bed9..ba745dee84 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -991,6 +991,21 @@ pub async fn handle_associate_stealth_resource( )); } + // Ensure the resource is in the local cache + if !sdk.resources_api().exists(&req.resource_address)? { + let substate = sdk + .substate_api() + .get_substate_from_network(req.resource_address.into()) + .await?; + let resource = substate.into_substate_value().into_resource().ok_or_else(|| { + general_error(format!( + "Indexer returned Substate at address {} is not a resource", + req.resource_address + )) + })?; + sdk.resources_api().upsert_resource(&req.resource_address, &resource)?; + } + sdk.accounts_api() .associate_stealth_resource(account.component_address(), req.resource_address)?; diff --git a/applications/tari_walletd/src/main.rs b/applications/tari_walletd/src/main.rs index d30754de39..4d662af8de 100644 --- a/applications/tari_walletd/src/main.rs +++ b/applications/tari_walletd/src/main.rs @@ -29,7 +29,6 @@ use tari_common::initialize_logging; use tari_crypto::tari_utilities::ByteArray; use tari_engine_types::ToByteType; use tari_ootle_app_utilities::configuration::load_configuration; -use tari_ootle_common_types::optional::Optional; use tari_ootle_wallet_sdk::{apis::key_manager::KeyBranch, cipher_seed::CipherSeedRestore}; use tari_ootle_walletd::{ cli::{Cli, Subcommand}, diff --git a/crates/engine/src/wasm/module.rs b/crates/engine/src/wasm/module.rs index 4013aba5ee..157637f5dd 100644 --- a/crates/engine/src/wasm/module.rs +++ b/crates/engine/src/wasm/module.rs @@ -46,6 +46,7 @@ use crate::{ limiting_tunable::LimitingTunables, metering, WasmExecutionError, + WasmProcess, WasmValidationError, }, }; @@ -88,6 +89,8 @@ impl WasmModule { let template = env.load_abi(&mut store, &instance)?; let main_fn = format!("{}_main", template.template_name()); + + WasmProcess::validate_template_tari_version(&template)?; validate_instance(&mut store, &instance, &main_fn)?; validate_functions(&template)?; diff --git a/crates/engine/src/wasm/process.rs b/crates/engine/src/wasm/process.rs index cc2e14a02b..2640e0938d 100644 --- a/crates/engine/src/wasm/process.rs +++ b/crates/engine/src/wasm/process.rs @@ -31,7 +31,7 @@ use tari_bor::{ encoded_len_with_limit, }; use tari_engine_types::{indexed_value::IndexedValue, instruction_result::InstructionResult, limits}; -use tari_template_abi::{version, CallInfo, EngineOp, FunctionDef}; +use tari_template_abi::{version, CallInfo, EngineOp, FunctionDef, TemplateDef}; use tari_template_lib::{ args::{ AddressAllocationInvokeArg, @@ -78,7 +78,7 @@ pub struct WasmProcess { impl WasmProcess { pub fn init(store: &mut Store, module: LoadedWasmTemplate, state: Runtime) -> Result { - Self::validate_template_tari_version(&module)?; + Self::validate_template_tari_version(module.template_def())?; let mut env = WasmEnv::new(state); let fn_env = FunctionEnv::new(store, env.clone()); @@ -273,8 +273,8 @@ impl WasmProcess { /// Determine if the version of the template_lib crate in the WASM is valid. /// This is just a placeholder that logs the result, as we don't manage version incompatibilities yet - fn validate_template_tari_version(module: &LoadedWasmTemplate) -> Result<(), WasmExecutionError> { - let template_tari_version = module.template_def().tari_version(); + pub fn validate_template_tari_version(template_def: &TemplateDef) -> Result<(), WasmExecutionError> { + let template_tari_version = template_def.tari_version(); if are_versions_compatible(template_tari_version, version::MINIMUM_SUPPORTED_TEMPLATE_LIB_VERSION)? { log::debug!(target: LOG_TARGET, "The Tari version in the template WASM (\"{}\") is compatible with the one used in the engine", template_tari_version); diff --git a/crates/engine/tests/templates/buggy/src/lib.rs b/crates/engine/tests/templates/buggy/src/lib.rs index 48f6d6e626..88daddaf0b 100644 --- a/crates/engine/tests/templates/buggy/src/lib.rs +++ b/crates/engine/tests/templates/buggy/src/lib.rs @@ -43,10 +43,10 @@ pub static _ABI_TEMPLATE_DEF: [u8; 4] = [0, 0, 0, 0]; feature = "no_template_def" )))] #[no_mangle] -pub static _ABI_TEMPLATE_DEF: [u8; 59] = [ - 55, 0, 0, 0, 161, 98, 86, 49, 163, 109, 116, 101, 109, 112, 108, 97, 116, 101, 95, 110, 97, 109, 101, 101, 66, 117, - 103, 103, 121, 108, 116, 97, 114, 105, 95, 118, 101, 114, 115, 105, 111, 110, 101, 48, 46, 49, 46, 48, 105, 102, - 117, 110, 99, 116, 105, 111, 110, 115, 128, +pub static _ABI_TEMPLATE_DEF: [u8; 60] = [ + 56, 0, 0, 0, 161, 98, 86, 49, 163, 109, 116, 101, 109, 112, 108, 97, 116, 101, 95, 110, 97, 109, 101, 101, 66, 117, + 103, 103, 121, 108, 116, 97, 114, 105, 95, 118, 101, 114, 115, 105, 111, 110, 102, 48, 46, 49, 52, 46, 48, 105, + 102, 117, 110, 99, 116, 105, 111, 110, 115, 128, ]; #[no_mangle] diff --git a/crates/engine/tests/test.rs b/crates/engine/tests/test.rs index b8a580e9ed..5f11c1f1fa 100644 --- a/crates/engine/tests/test.rs +++ b/crates/engine/tests/test.rs @@ -152,7 +152,7 @@ fn test_buggy_template() { // Uncomment the following lines to print the ABI bytes // let bytes = tari_bor::encode_with_len(&tari_template_abi::TemplateDef::V1(tari_template_abi::TemplateDefV1 { // template_name: "Buggy".to_string(), - // tari_version: "0.1.0".to_string(), + // tari_version: tari_template_abi::version::MINIMUM_SUPPORTED_TEMPLATE_LIB_VERSION, // functions: vec![], // })); // println!("pub static _ABI_TEMPLATE_DEF: [u8; {}] = [", bytes.len()); diff --git a/crates/wallet/sdk/src/apis/substate.rs b/crates/wallet/sdk/src/apis/substate.rs index b9925f49e9..5e75a38b83 100644 --- a/crates/wallet/sdk/src/apis/substate.rs +++ b/crates/wallet/sdk/src/apis/substate.rs @@ -63,6 +63,12 @@ where Ok(substates) } + pub async fn get_substate_from_network(&self, id: SubstateId) -> Result { + let mut map = self.get_substates_from_network(vec![id.clone()]).await?; + map.remove(&id) + .ok_or_else(|| SubstateApiError::SubstateDoesNotExist { address: id }) + } + pub async fn get_substates_from_network( &self, ids: Vec, diff --git a/crates/wallet/sdk_services/src/account_monitor/handle.rs b/crates/wallet/sdk_services/src/account_monitor/handle.rs new file mode 100644 index 0000000000..3dc39f2cc1 --- /dev/null +++ b/crates/wallet/sdk_services/src/account_monitor/handle.rs @@ -0,0 +1,51 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_template_lib::models::ComponentAddress; +use tokio::sync::{mpsc, oneshot}; + +use crate::{account_monitor::monitor::AccountMonitorError, Reply}; + +#[derive(Debug)] +pub(super) enum AccountMonitorRequest { + RefreshAccount { + account: ComponentAddress, + scan_for_utxos: bool, + reply: Reply>, + }, +} + +#[derive(Debug, Clone)] +pub struct AccountMonitorHandle { + pub(super) sender: mpsc::Sender, +} + +impl AccountMonitorHandle { + /// Triggers an immediate refresh of the specified account. Returns `true` if the account was updated, otherwise + /// `false`. + pub async fn refresh_account(&self, account: ComponentAddress) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.sender + .send(AccountMonitorRequest::RefreshAccount { + account, + scan_for_utxos: false, + reply: reply_tx, + }) + .await + .map_err(|_| AccountMonitorError::ServiceShutdown)?; + reply_rx.await.map_err(|_| AccountMonitorError::ServiceShutdown)? + } + + pub async fn refresh_account_with_utxos(&self, account: ComponentAddress) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.sender + .send(AccountMonitorRequest::RefreshAccount { + account, + scan_for_utxos: true, + reply: reply_tx, + }) + .await + .map_err(|_| AccountMonitorError::ServiceShutdown)?; + reply_rx.await.map_err(|_| AccountMonitorError::ServiceShutdown)? + } +} diff --git a/crates/wallet/sdk_services/src/account_monitor/mod.rs b/crates/wallet/sdk_services/src/account_monitor/mod.rs new file mode 100644 index 0000000000..13de460717 --- /dev/null +++ b/crates/wallet/sdk_services/src/account_monitor/mod.rs @@ -0,0 +1,10 @@ +// Copyright 2023 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +mod handle; +mod monitor; +mod scanner; + +pub use handle::*; +pub use monitor::*; +pub use scanner::*; diff --git a/crates/wallet/sdk_services/src/account_monitor/monitor.rs b/crates/wallet/sdk_services/src/account_monitor/monitor.rs new file mode 100644 index 0000000000..7edc13c5cf --- /dev/null +++ b/crates/wallet/sdk_services/src/account_monitor/monitor.rs @@ -0,0 +1,269 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use std::{collections::HashMap, time::Duration}; + +use log::*; +use tari_engine_types::indexed_value::IndexedValueError; +use tari_ootle_common_types::optional::IsNotFoundError; +use tari_ootle_wallet_sdk::{ + apis::{ + accounts::AccountsApiError, + confidential_outputs::ConfidentialOutputsApiError, + non_fungible_tokens::NonFungibleTokensApiError, + resources::ResourcesApiError, + stealth_outputs::StealthOutputsApiError, + substate::SubstateApiError, + transaction::TransactionApiError, + }, + models::NewAccountData, + network::{StatusResponseError, WalletNetworkInterface}, + storage::WalletStore, + WalletSdk, +}; +use tari_shutdown::ShutdownSignal; +use tari_template_lib::prelude::ComponentAddress; +use tari_transaction::TransactionId; +use tokio::{ + sync::{broadcast, mpsc}, + time, + time::MissedTickBehavior, +}; + +use crate::{ + account_monitor::{ + handle::{AccountMonitorHandle, AccountMonitorRequest}, + scanner::AccountScanner, + }, + events::WalletEvent, + notify::Notify, + utxo_scanner::UtxoScannerHandle, +}; + +const LOG_TARGET: &str = "tari::ootle::wallet_services::account_monitor"; + +pub struct AccountMonitor { + notify_subscription: broadcast::Receiver, + wallet_sdk: WalletSdk, + request_rx: mpsc::Receiver, + pending_accounts: HashMap, + utxo_scanner_handle: UtxoScannerHandle, + periodic_scan_interval: Duration, + enable_periodic_scanning_with_utxos: bool, + scanner: AccountScanner, + shutdown_signal: ShutdownSignal, +} + +impl AccountMonitor +where + TStore: WalletStore + Clone, + TNetworkInterface: WalletNetworkInterface + Clone, + TNetworkInterface::Error: IsNotFoundError + StatusResponseError, +{ + pub fn new( + notify: Notify, + wallet_sdk: WalletSdk, + utxo_scanner_handle: UtxoScannerHandle, + shutdown_signal: ShutdownSignal, + ) -> (Self, AccountMonitorHandle) { + let (request_tx, request_rx) = mpsc::channel(1); + + ( + Self { + notify_subscription: notify.subscribe(), + wallet_sdk: wallet_sdk.clone(), + request_rx, + pending_accounts: HashMap::new(), + periodic_scan_interval: Duration::from_secs(60), + utxo_scanner_handle, + enable_periodic_scanning_with_utxos: true, + scanner: AccountScanner::new(notify, wallet_sdk), + shutdown_signal, + }, + AccountMonitorHandle { sender: request_tx }, + ) + } + + pub fn with_periodic_scan_interval(mut self, interval: Duration) -> Self { + self.periodic_scan_interval = interval; + self + } + + pub fn disable_periodic_scanning_with_utxos(mut self) -> Self { + self.enable_periodic_scanning_with_utxos = false; + self + } + + pub async fn run(mut self) -> Result<(), anyhow::Error> { + info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor started"); + let mut poll_interval = time::interval(self.periodic_scan_interval); + poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = self.shutdown_signal.wait() => { + info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor shutting down"); + break Ok(()); + } + + _ = poll_interval.tick() => { + trace!(target: LOG_TARGET, "Polling for transactions"); + self.on_poll().await; + } + + Some(req) = self.request_rx.recv() => { + self.handle_request(req).await; + } + + Ok(event) = self.notify_subscription.recv() => { + if let Err(e) = self.on_event(event).await { + error!(target: LOG_TARGET, "Error handling event: {}", e); + } + }, + } + } + } + + async fn handle_request(&self, req: AccountMonitorRequest) { + debug!(target: LOG_TARGET, "👁️‍🗨️ Account monitor received request: {:?}", req); + match req { + AccountMonitorRequest::RefreshAccount { + account, + scan_for_utxos, + reply, + } => { + let _ignore = reply.send(self.refresh_account(account, scan_for_utxos).await); + }, + } + } + + async fn on_poll(&self) { + if let Err(err) = self.refresh_all_accounts().await { + error!(target: LOG_TARGET, "Error refreshing all accounts: {}", err); + } + } + + async fn refresh_all_accounts(&self) -> Result<(), AccountMonitorError> { + let accounts_api = self.wallet_sdk.accounts_api(); + // TODO: There could be more than 100 accounts + let accounts = accounts_api.get_many(0, 100)?; + for account in accounts { + let is_updated = self.scanner.refresh_account(*account.component_address()).await?; + if self.enable_periodic_scanning_with_utxos { + self.refresh_stealth_utxos(*account.component_address()).await?; + } + + if is_updated { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} has been updated", account + ); + } else { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} is up to date", account + ); + } + } + Ok(()) + } + + async fn refresh_account( + &self, + account_address: ComponentAddress, + scan_for_utxos: bool, + ) -> Result { + let is_updated = self.scanner.refresh_account(account_address).await?; + if scan_for_utxos { + self.refresh_stealth_utxos(account_address).await?; + } + if is_updated { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} updated", account_address + ); + } else { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} is up to date", account_address + ); + } + Ok(is_updated) + } + + async fn refresh_stealth_utxos(&self, account_address: ComponentAddress) -> Result<(), AccountMonitorError> { + let associated_resources = self + .wallet_sdk + .accounts_api() + .get_associated_stealth_resources(&account_address)?; + + info!( + target: LOG_TARGET, + "👁️‍🗨️ Requesting UTXO scan for account {} for {} stealth resource(s)", + account_address, + associated_resources.len() + ); + for resource_address in associated_resources { + self.utxo_scanner_handle.request_scan(account_address, resource_address); + } + Ok(()) + } + + async fn on_event(&mut self, event: WalletEvent) -> Result<(), AccountMonitorError> { + match event { + WalletEvent::TransactionSubmitted(event) => { + if let Some(account) = event.new_account { + self.pending_accounts.insert(event.transaction_id, account); + } + }, + WalletEvent::TransactionFinalized(event) => { + if let Some(diff) = event.finalize.result.any_accept() { + let new_account = self.pending_accounts.remove(&event.transaction_id); + self.scanner + .process_result(event.transaction_id, diff, new_account) + .await?; + } + }, + WalletEvent::TransactionInvalid(event) => { + self.pending_accounts.remove(&event.transaction_id); + }, + WalletEvent::AccountCreatedOnChain(_) | + WalletEvent::AccountChangedOnChain(_) | + WalletEvent::AuthLoginRequest(_) | + WalletEvent::UtxoRecoveryStarted(_) | + WalletEvent::UtxoRecovered(_) | + WalletEvent::UtxoRecoveryCompleted(_) => {}, + } + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AccountMonitorError { + #[error("Transaction API error: {0}")] + Transaction(#[from] TransactionApiError), + #[error("Accounts API error: {0}")] + Accounts(#[from] AccountsApiError), + #[error("Substate API error: {0}")] + Substate(#[from] SubstateApiError), + #[error("Outputs API error: {0}")] + ConfidentialOutputs(#[from] ConfidentialOutputsApiError), + #[error("Stealth Outputs API error: {0}")] + StealthOutputs(#[from] StealthOutputsApiError), + #[error("Non Fungibles API error: {0}")] + NonFungibleTokens(#[from] NonFungibleTokensApiError), + #[error("Resources API error: {0}")] + Resources(#[from] ResourcesApiError), + #[error("Failed to decode binary value: {0}")] + DecodeValueFailed(#[from] IndexedValueError), + #[error("Unexpected substate: {0}")] + UnexpectedSubstate(String), + #[error("Monitor service is not running")] + ServiceShutdown, +} + +impl IsNotFoundError for AccountMonitorError { + fn is_not_found_error(&self) -> bool { + matches!(self, Self::Substate(s) if s.is_not_found_error()) + } +} diff --git a/crates/wallet/sdk_services/src/account_monitor.rs b/crates/wallet/sdk_services/src/account_monitor/scanner.rs similarity index 67% rename from crates/wallet/sdk_services/src/account_monitor.rs rename to crates/wallet/sdk_services/src/account_monitor/scanner.rs index 39c37527a6..49c480089b 100644 --- a/crates/wallet/sdk_services/src/account_monitor.rs +++ b/crates/wallet/sdk_services/src/account_monitor/scanner.rs @@ -1,14 +1,11 @@ -// Copyright 2023 The Tari Project +// Copyright 2025 The Tari Project // SPDX-License-Identifier: BSD-3-Clause -use std::{ - collections::{HashMap, HashSet}, - time::Duration, -}; +use std::collections::{HashMap, HashSet}; use log::*; use tari_engine_types::{ - indexed_value::{IndexedValueError, IndexedWellKnownTypes}, + indexed_value::IndexedWellKnownTypes, non_fungible::NonFungibleContainer, resource::Resource, substate::{Substate, SubstateDiff, SubstateId, SubstateValue}, @@ -16,171 +13,44 @@ use tari_engine_types::{ }; use tari_ootle_common_types::optional::{IsNotFoundError, Optional}; use tari_ootle_wallet_sdk::{ - apis::{ - accounts::AccountsApiError, - confidential_outputs::ConfidentialOutputsApiError, - non_fungible_tokens::NonFungibleTokensApiError, - resources::ResourcesApiError, - stealth_outputs::StealthOutputsApiError, - substate::{SubstateApiError, ValidatorScanResult}, - transaction::TransactionApiError, - }, + apis::substate::ValidatorScanResult, models::{AccountUpdate, NewAccountData, NonFungibleToken}, network::{StatusResponseError, WalletNetworkInterface}, storage::WalletStore, WalletSdk, }; -use tari_shutdown::ShutdownSignal; use tari_template_builtin::ACCOUNT_TEMPLATE_ADDRESS; use tari_template_lib::{ models::{NonFungibleAddress, VaultId}, - prelude::{ComponentAddress, NonFungibleId, ResourceAddress, XTR}, + prelude::{ComponentAddress, NonFungibleId, ResourceAddress}, resource::TOKEN_SYMBOL, }; use tari_transaction::TransactionId; -use tokio::{ - sync::{mpsc, oneshot}, - time, - time::MissedTickBehavior, -}; use crate::{ + account_monitor::monitor::AccountMonitorError, events::{AccountChangedEvent, AccountCreatedEvent, WalletEvent}, notify::Notify, - utxo_scanner::UtxoScannerHandle, - Reply, }; const LOG_TARGET: &str = "tari::ootle::wallet_services::account_monitor"; -pub struct AccountMonitor { +pub struct AccountScanner { notify: Notify, wallet_sdk: WalletSdk, - request_rx: mpsc::Receiver, - pending_accounts: HashMap, - utxo_scanner_handle: UtxoScannerHandle, - periodic_scan_interval: Duration, - enable_periodic_scanning_with_utxos: bool, - shutdown_signal: ShutdownSignal, } -impl AccountMonitor +impl AccountScanner where TStore: WalletStore, TNetworkInterface: WalletNetworkInterface, TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { - pub fn new( - notify: Notify, - wallet_sdk: WalletSdk, - utxo_scanner_handle: UtxoScannerHandle, - shutdown_signal: ShutdownSignal, - ) -> (Self, AccountMonitorHandle) { - let (request_tx, request_rx) = mpsc::channel(1); - - ( - Self { - notify, - wallet_sdk, - request_rx, - pending_accounts: HashMap::new(), - periodic_scan_interval: Duration::from_secs(60), - utxo_scanner_handle, - enable_periodic_scanning_with_utxos: true, - shutdown_signal, - }, - AccountMonitorHandle { sender: request_tx }, - ) - } - - pub fn with_periodic_scan_interval(mut self, interval: Duration) -> Self { - self.periodic_scan_interval = interval; - self - } - - pub fn disable_periodic_scanning_with_utxos(mut self) -> Self { - self.enable_periodic_scanning_with_utxos = false; - self - } - - pub async fn run(mut self) -> Result<(), anyhow::Error> { - info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor started"); - let mut events_subscription = self.notify.subscribe(); - let mut poll_interval = time::interval(self.periodic_scan_interval); - poll_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - - loop { - tokio::select! { - _ = self.shutdown_signal.wait() => { - info!(target: LOG_TARGET, "👁️‍🗨️ Account monitor shutting down"); - break Ok(()); - } - - _ = poll_interval.tick() => { - trace!(target: LOG_TARGET, "Polling for transactions"); - self.on_poll().await; - } - - Some(req) = self.request_rx.recv() => { - self.handle_request(req).await; - } - - Ok(event) = events_subscription.recv() => { - if let Err(e) = self.on_event(event).await { - error!(target: LOG_TARGET, "Error handling event: {}", e); - } - }, - } - } - } - - async fn handle_request(&self, req: AccountMonitorRequest) { - debug!(target: LOG_TARGET, "👁️‍🗨️ Account monitor received request: {:?}", req); - match req { - AccountMonitorRequest::RefreshAccount { - account, - scan_for_utxos, - reply, - } => { - let _ignore = reply.send(self.refresh_account(account, scan_for_utxos).await); - }, - } - } - - async fn on_poll(&self) { - if let Err(err) = self.refresh_all_accounts().await { - error!(target: LOG_TARGET, "Error refreshing all accounts: {}", err); - } - } - - async fn refresh_all_accounts(&self) -> Result<(), AccountMonitorError> { - let accounts_api = self.wallet_sdk.accounts_api(); - // TODO: There could be more than 100 accounts - let accounts = accounts_api.get_many(0, 100)?; - for account in accounts { - let is_updated = self - .refresh_account(*account.component_address(), self.enable_periodic_scanning_with_utxos) - .await?; - - if is_updated { - self.notify.notify(AccountChangedEvent { - account_address: *account.component_address(), - }); - } else { - info!( - target: LOG_TARGET, - "👁️‍🗨️ Account {} is up to date", account - ); - } - } - Ok(()) + pub fn new(notify: Notify, wallet_sdk: WalletSdk) -> Self { + Self { notify, wallet_sdk } } - async fn refresh_account( - &self, - account_address: ComponentAddress, - scan_for_utxos: bool, - ) -> Result { + pub async fn refresh_account(&self, account_address: ComponentAddress) -> Result { info!( target: LOG_TARGET, "👁️‍🗨️ Refreshing account {}", account_address @@ -193,14 +63,6 @@ where return Ok(false); }; - let mut associated_resources = accounts_api.get_associated_stealth_resources(account.component_address())?; - associated_resources.insert(XTR); - // Stealth outputs reference resources, so we need to ensure we have an entry for them. If they are already - // cached, this is a relatively cheap check. - for addr in &associated_resources { - self.ensure_resource_is_cached(addr).await?; - } - let mut is_updated = false; let maybe_scan_result = substate_api .fetch_substate_from_network(&account_address.into(), None) @@ -217,11 +79,6 @@ where } // Otherwise, the account is not on-chain, so we wouldn't expect the indexer to have it - if scan_for_utxos { - // Scan for associated stealth resources - self.refresh_stealth_utxos(associated_resources, account_address)?; - } - return Ok(false); }; @@ -275,39 +132,6 @@ where continue; }; - // // TODO: this is expensive for many NFTs - // let mut nfts = HashMap::with_capacity(latest_vault.get_non_fungible_ids().len()); - // if !latest_vault.get_non_fungible_ids().is_empty() { - // info!( - // target: LOG_TARGET, - // "Found {} non-fungible(s) in vault {}. Collecting NFT data for update", - // latest_vault.get_non_fungible_ids().len(), - // vault_id - // ); - // } - // for nft in latest_vault.get_non_fungible_ids() { - // let addr = NonFungibleAddress::new(*latest_vault.resource_address(), nft.clone()); - // let Some(ValidatorScanResult { address, substate }) = - // substate_api.scan_for_substate(&addr.into(), None).await.optional()? - // else { - // warn!(target: LOG_TARGET, "Non-fungible {} for vault {} does not exist according to indexer", - // nft, vault_id); continue; - // }; - // substate_api.save_child(versioned_vault_substate_id.substate_id(), address.as_ref(), [ - // (*latest_vault.resource_address()).into(), - // ])?; - // let nft_container = substate.into_non_fungible().ok_or_else(|| { - // AccountMonitorError::UnexpectedSubstate(format!("Expected {} to be a non-fungible token.", nft)) - // })?; - // info!( - // target: LOG_TARGET, - // "Found non-fungible {} in vault {}", - // nft, - // vault_id - // ); - // nfts.insert(nft.clone(), nft_container); - // } - is_updated = true; // Save the vault substate @@ -323,33 +147,13 @@ where self.refresh_vault(account_addr, *vault_id, &latest_vault, Default::default()) .await?; } - } - if scan_for_utxos { - // Scan for all stealth resources - self.refresh_stealth_utxos(associated_resources, account_address)?; + self.notify.notify(AccountChangedEvent { account_address }); } Ok(is_updated) } - fn refresh_stealth_utxos( - &self, - stealth_resources: HashSet, - account_address: ComponentAddress, - ) -> Result<(), AccountMonitorError> { - info!( - target: LOG_TARGET, - "👁️‍🗨️ Requesting UTXO scan for account {} for {} stealth resource(s)", - account_address, - stealth_resources.len() - ); - for resource_address in stealth_resources { - self.utxo_scanner_handle.request_scan(account_address, resource_address); - } - Ok(()) - } - #[allow(clippy::too_many_lines)] async fn refresh_vault( &self, @@ -357,13 +161,13 @@ where vault_id: VaultId, latest_vault: &Vault, updated_nft_data: HashMap, - ) -> Result<(), AccountMonitorError> { + ) -> Result { let accounts_api = self.wallet_sdk.accounts_api(); let new_balance = latest_vault.balance(); if !accounts_api.exists_by_address(&account_address)? { // This is not our account - return Ok(()); + return Ok(false); } let mut has_changed = false; @@ -460,11 +264,8 @@ where accounts_api.update_vault_balance(vault_id, new_balance, new_confidential_balance)?; has_changed = true; } - if has_changed { - self.notify.notify(AccountChangedEvent { account_address }); - } - Ok(()) + Ok(has_changed) } async fn associate_resource_with_account( @@ -492,6 +293,19 @@ where Ok(()) } + async fn fetch_and_cache_resource(&self, resx_addr: &ResourceAddress) -> Result { + if let Some(resx) = self.wallet_sdk.resources_api().get(resx_addr).optional()? { + return Ok(resx); + } + + let substate = self.fetch_substate(&SubstateId::Resource(*resx_addr)).await?; + let resx = substate.into_substate_value().into_resource().ok_or_else(|| { + AccountMonitorError::UnexpectedSubstate(format!("Expected {} to be a resource.", resx_addr)) + })?; + self.wallet_sdk.resources_api().upsert_resource(resx_addr, &resx)?; + Ok(resx) + } + async fn update_vault_nfts( &self, vault_id: VaultId, @@ -595,12 +409,17 @@ where } #[allow(clippy::too_many_lines)] - async fn process_result(&mut self, tx_id: TransactionId, diff: &SubstateDiff) -> Result<(), AccountMonitorError> { + pub async fn process_result( + &mut self, + tx_id: TransactionId, + diff: &SubstateDiff, + new_account_data: Option, + ) -> Result<(), AccountMonitorError> { let substate_api = self.wallet_sdk.substate_api(); let accounts_api = self.wallet_sdk.accounts_api(); let mut new_account = None; - if let Some(account) = self.pending_accounts.remove(&tx_id) { + if let Some(account) = new_account_data { let existing_account = accounts_api.get_account_by_address(&account.address).optional()?; // Check that the new account was created in this transaction if diff.up_iter().any(|(id, _)| *id == account.address) { @@ -656,14 +475,19 @@ where } }); + let mut updated_accounts = HashSet::new(); // Find and process all new/existing vaults - for (account_addr, value) in accounts { + for (account_address, value) in accounts { // If we know about this account, mark it as on-chain (if it isn't already) - self.mark_account_as_on_chain(&account_addr).optional()?; + if self.mark_account_as_on_chain(&account_address).optional()?.is_none() { + continue; + } + let mut has_changed = false; for vault_id in value.vault_ids() { // Any vaults we process here do not need to be reprocesed later if let Some(vault) = vaults.remove(vault_id).and_then(|s| s.substate_value().vault()) { - self.add_vault_to_account_if_not_exist(&account_addr, *vault_id, vault) + has_changed |= self + .add_vault_to_account_if_not_exist(&account_address, *vault_id, vault) .await?; let updated_nfts = vault @@ -682,12 +506,17 @@ where }) .collect(); - self.refresh_vault(account_addr, *vault_id, vault, updated_nfts).await?; + has_changed |= self + .refresh_vault(account_address, *vault_id, vault, updated_nfts) + .await?; } } + + if has_changed { + updated_accounts.insert(account_address); + } } - let mut updated_accounts = HashSet::new(); // Process all existing vaults that belong to an account for (vault_id, substate) in vaults { let vault_addr = SubstateId::Vault(vault_id); @@ -790,19 +619,6 @@ where Ok(()) } - async fn fetch_and_cache_resource(&self, resx_addr: &ResourceAddress) -> Result { - if let Some(resx) = self.wallet_sdk.resources_api().get(resx_addr).optional()? { - return Ok(resx); - } - - let substate = self.fetch_substate(&SubstateId::Resource(*resx_addr)).await?; - let resx = substate.into_substate_value().into_resource().ok_or_else(|| { - AccountMonitorError::UnexpectedSubstate(format!("Expected {} to be a resource.", resx_addr)) - })?; - self.wallet_sdk.resources_api().upsert_resource(resx_addr, &resx)?; - Ok(resx) - } - async fn fetch_substate(&self, substate_id: &SubstateId) -> Result { let substate_api = self.wallet_sdk.substate_api(); let ValidatorScanResult { substate, id: address } = @@ -825,14 +641,14 @@ where account_addr: &ComponentAddress, vault_id: VaultId, vault: &Vault, - ) -> Result<(), AccountMonitorError> { + ) -> Result { let accounts_api = self.wallet_sdk.accounts_api(); if !accounts_api.exists_by_address(account_addr)? { // This is not our account - return Ok(()); + return Ok(false); } if accounts_api.has_vault(&vault_id)? { - return Ok(()); + return Ok(false); } let maybe_resource = match self.fetch_and_cache_resource(vault.resource_address()).await { Ok(r) => Some(r), @@ -867,103 +683,7 @@ where divisibility, )?; - Ok(()) - } - - async fn on_event(&mut self, event: WalletEvent) -> Result<(), AccountMonitorError> { - match event { - WalletEvent::TransactionSubmitted(event) => { - if let Some(account) = event.new_account { - self.pending_accounts.insert(event.transaction_id, account); - } - }, - WalletEvent::TransactionFinalized(event) => { - if let Some(diff) = event.finalize.result.any_accept() { - self.process_result(event.transaction_id, diff).await?; - } - }, - WalletEvent::TransactionInvalid(event) => { - self.pending_accounts.remove(&event.transaction_id); - }, - WalletEvent::AccountCreatedOnChain(_) | - WalletEvent::AccountChangedOnChain(_) | - WalletEvent::AuthLoginRequest(_) => {}, - } - Ok(()) - } -} - -#[derive(Debug)] -enum AccountMonitorRequest { - RefreshAccount { - account: ComponentAddress, - scan_for_utxos: bool, - reply: Reply>, - }, -} - -#[derive(Debug, Clone)] -pub struct AccountMonitorHandle { - sender: mpsc::Sender, -} - -impl AccountMonitorHandle { - /// Triggers an immediate refresh of the specified account. Returns `true` if the account was updated, otherwise - /// `false`. - pub async fn refresh_account(&self, account: ComponentAddress) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - self.sender - .send(AccountMonitorRequest::RefreshAccount { - account, - scan_for_utxos: false, - reply: reply_tx, - }) - .await - .map_err(|_| AccountMonitorError::ServiceShutdown)?; - reply_rx.await.map_err(|_| AccountMonitorError::ServiceShutdown)? - } - - pub async fn refresh_account_with_utxos(&self, account: ComponentAddress) -> Result { - let (reply_tx, reply_rx) = oneshot::channel(); - self.sender - .send(AccountMonitorRequest::RefreshAccount { - account, - scan_for_utxos: true, - reply: reply_tx, - }) - .await - .map_err(|_| AccountMonitorError::ServiceShutdown)?; - reply_rx.await.map_err(|_| AccountMonitorError::ServiceShutdown)? - } -} - -#[derive(Debug, thiserror::Error)] -pub enum AccountMonitorError { - #[error("Transaction API error: {0}")] - Transaction(#[from] TransactionApiError), - #[error("Accounts API error: {0}")] - Accounts(#[from] AccountsApiError), - #[error("Substate API error: {0}")] - Substate(#[from] SubstateApiError), - #[error("Outputs API error: {0}")] - ConfidentialOutputs(#[from] ConfidentialOutputsApiError), - #[error("Stealth Outputs API error: {0}")] - StealthOutputs(#[from] StealthOutputsApiError), - #[error("Non Fungibles API error: {0}")] - NonFungibleTokens(#[from] NonFungibleTokensApiError), - #[error("Resources API error: {0}")] - Resources(#[from] ResourcesApiError), - #[error("Failed to decode binary value: {0}")] - DecodeValueFailed(#[from] IndexedValueError), - #[error("Unexpected substate: {0}")] - UnexpectedSubstate(String), - #[error("Monitor service is not running")] - ServiceShutdown, -} - -impl IsNotFoundError for AccountMonitorError { - fn is_not_found_error(&self) -> bool { - matches!(self, Self::Substate(s) if s.is_not_found_error()) + Ok(true) } } diff --git a/crates/wallet/sdk_services/src/events.rs b/crates/wallet/sdk_services/src/events.rs index c8407bb48f..db5683262d 100644 --- a/crates/wallet/sdk_services/src/events.rs +++ b/crates/wallet/sdk_services/src/events.rs @@ -3,7 +3,7 @@ use tari_engine_types::commit_result::FinalizeResult; use tari_ootle_wallet_sdk::models::{Account, NewAccountData, TransactionStatus}; -use tari_template_lib::prelude::ComponentAddress; +use tari_template_lib::{models::UtxoAddress, prelude::ComponentAddress}; use tari_transaction::TransactionId; #[derive(Debug, Clone)] @@ -14,6 +14,9 @@ pub enum WalletEvent { AccountCreatedOnChain(AccountCreatedEvent), AccountChangedOnChain(AccountChangedEvent), AuthLoginRequest(#[allow(dead_code)] AuthLoginRequestEvent), + UtxoRecoveryStarted(UtxoRecoveryStartedEvent), + UtxoRecovered(UtxoRecoveredEvent), + UtxoRecoveryCompleted(UtxoRecoveryCompletedEvent), } impl From for WalletEvent { @@ -52,6 +55,24 @@ impl From for WalletEvent { } } +impl From for WalletEvent { + fn from(value: UtxoRecoveredEvent) -> Self { + Self::UtxoRecovered(value) + } +} + +impl From for WalletEvent { + fn from(value: UtxoRecoveryStartedEvent) -> Self { + Self::UtxoRecoveryStarted(value) + } +} + +impl From for WalletEvent { + fn from(value: UtxoRecoveryCompletedEvent) -> Self { + Self::UtxoRecoveryCompleted(value) + } +} + #[derive(Debug, Clone)] pub struct TransactionSubmittedEvent { pub transaction_id: TransactionId, @@ -88,3 +109,20 @@ pub struct TransactionInvalidEvent { #[derive(Debug, Clone)] pub struct AuthLoginRequestEvent; + +#[derive(Debug, Clone)] +pub struct UtxoRecoveredEvent { + pub address: UtxoAddress, + pub account_address: ComponentAddress, +} + +#[derive(Debug, Clone)] +pub struct UtxoRecoveryStartedEvent { + pub round_id: usize, +} + +#[derive(Debug, Clone)] +pub struct UtxoRecoveryCompletedEvent { + pub round_id: usize, + pub num_recovered: usize, +} diff --git a/crates/wallet/sdk_services/src/transaction_service/service.rs b/crates/wallet/sdk_services/src/transaction_service/service.rs index ce141e0e83..f53b2146a7 100644 --- a/crates/wallet/sdk_services/src/transaction_service/service.rs +++ b/crates/wallet/sdk_services/src/transaction_service/service.rs @@ -306,7 +306,10 @@ where WalletEvent::TransactionFinalized(_) | WalletEvent::AccountChangedOnChain(_) | WalletEvent::AuthLoginRequest(_) | - WalletEvent::AccountCreatedOnChain(_) => {}, + WalletEvent::AccountCreatedOnChain(_) | + WalletEvent::UtxoRecoveryStarted(_) | + WalletEvent::UtxoRecovered(_) | + WalletEvent::UtxoRecoveryCompleted(_) => {}, } Ok(()) } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs index 0caa38a048..a3995e5900 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs @@ -9,7 +9,6 @@ use tari_ootle_wallet_sdk::{ WalletSdk, }; use tari_template_lib::models::ResourceAddress; -use tokio::sync::watch; use crate::utxo_scanner::{StealthScannerApiError, UtxoScannerRound}; @@ -31,7 +30,6 @@ where &self, account: &AccountWithAddress, resource_address: &ResourceAddress, - notify_tx: &watch::Sender<()>, ) -> Result { let network = self.sdk.config_api().get_network()?; @@ -40,8 +38,7 @@ where .key_manager_api() .get_view_only_key(account.view_only_key_id())?; - let mut scanner_round = - UtxoScannerRound::new(network, &self.sdk, notify_tx, account, &view_key, resource_address); + let mut scanner_round = UtxoScannerRound::new(network, &self.sdk, account, &view_key, resource_address); let num_found = scanner_round.scan_for_utxo_updates().await?; Ok(num_found) diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs index ca091e0f2f..1a30459de7 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs @@ -20,7 +20,6 @@ use tari_ootle_wallet_sdk::{ WalletSdk, }; use tari_template_lib::models::{ComponentAddress, ResourceAddress}; -use tokio::sync::watch; use crate::utxo_scanner::StealthScannerApiError; @@ -35,7 +34,6 @@ pub struct UtxoScannerRound<'a, TStore, TNetworkInterface> { resource_address: &'a ResourceAddress, sdk: &'a WalletSdk, - notify_tx: &'a watch::Sender<()>, shard_state_versions_to_set: HashMap, utxos_to_recover: Vec<(ComponentAddress, UtxoUnspent)>, @@ -51,7 +49,6 @@ where pub fn new( network: Network, sdk: &'a WalletSdk, - notify_tx: &'a watch::Sender<()>, account: &'a AccountWithAddress, view_key: &'a Key, resource_address: &'a ResourceAddress, @@ -59,7 +56,6 @@ where Self { network, sdk, - notify_tx, account, view_key, resource_address, @@ -78,12 +74,6 @@ where num_found += 1; } - if num_found > 0 { - // Notify that there are new UTXOs to process - debug!(target: LOG_TARGET, "Notifying that new UTXOs are available for processing"); - let _ignore = self.notify_tx.send(()); - } - Ok(num_found) } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs index 11ed4956ec..68a03e3f19 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/utxo_recovery.rs @@ -16,23 +16,19 @@ use tari_ootle_wallet_sdk::{ WalletSdk, }; use tari_template_lib::models::{ComponentAddress, ResourceAddress, UtxoAddress, UtxoId}; -use tokio::sync::{broadcast, watch}; +use tokio::sync::watch; -use crate::utxo_scanner::StealthScannerApiError; +use crate::{ + events::{UtxoRecoveredEvent, UtxoRecoveryCompletedEvent, UtxoRecoveryStartedEvent, WalletEvent}, + notify::Notify, + utxo_scanner::StealthScannerApiError, +}; const LOG_TARGET: &str = "tari::ootle::wallet_services::utxo_recovery"; -#[derive(Debug, Clone)] -pub enum UtxoRecoveryEvent { - UtxoRecoveryRoundStarted { round_id: usize }, - UtxoRecoveryRoundBatchStarted { round_id: usize, batch_size: usize }, - UtxoRecovered { utxo_address: UtxoAddress }, - UtxoRecoveryRoundCompleted { round_id: usize }, -} - pub struct UtxoRecovery { sdk: WalletSdk, - events: Option>, + notify: Option>, round_id: usize, } @@ -45,13 +41,13 @@ where pub fn new(sdk: WalletSdk) -> Self { Self { sdk, - events: None, + notify: None, round_id: 0, } } - pub fn with_events(mut self, events: broadcast::Sender) -> Self { - self.events = Some(events); + pub fn with_notify(mut self, events: Notify) -> Self { + self.notify = Some(events); self } @@ -88,14 +84,15 @@ where Ok(()) } - fn publish_event(&self, event: UtxoRecoveryEvent) { - if let Some(events) = &self.events { - let _ = events.send(event); + fn notify>(&self, event: T) { + if let Some(notify) = &self.notify { + notify.notify(event); } } pub async fn process_utxo_validation_queue(&mut self) -> Result<(), StealthScannerApiError> { let mut start_event_published = false; + let mut num_recovered = 0; loop { let batch = self .sdk @@ -106,11 +103,12 @@ where debug!(target: LOG_TARGET, "✅ No more UTXOs to process"); if self.round_id == 0 { - self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundStarted { + self.notify(UtxoRecoveryStartedEvent { round_id: self.round_id, }); - self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundCompleted { + self.notify(UtxoRecoveryCompletedEvent { round_id: self.round_id, + num_recovered, }); self.round_id += 1; @@ -120,17 +118,12 @@ where if !start_event_published { self.round_id += 1; - self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundStarted { + self.notify(UtxoRecoveryStartedEvent { round_id: self.round_id, }); start_event_published = true; } - self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundBatchStarted { - round_id: self.round_id, - batch_size: batch.len(), - }); - for (resource_addr, tag_and_nonce_to_view_key_map) in &batch { if tag_and_nonce_to_view_key_map.is_empty() { error!(target: LOG_TARGET, "❓️ NEVER HAPPEN: Asked indexer for zero UTXOs for resource {}.", resource_addr); @@ -194,13 +187,13 @@ where }) }) .collect(); - - self.process_recovered_utxos(*resource_addr, utxos)?; + num_recovered += self.process_recovered_utxos(*resource_addr, utxos)?; } if start_event_published { - self.publish_event(UtxoRecoveryEvent::UtxoRecoveryRoundCompleted { + self.notify(UtxoRecoveryCompletedEvent { round_id: self.round_id, + num_recovered, }); } } @@ -210,7 +203,7 @@ where &self, resource_address: ResourceAddress, utxos_to_recover: Vec, - ) -> Result<(), StealthScannerApiError> { + ) -> Result { let num_attempted = utxos_to_recover.len(); let mut num_recovered = 0; for utxo in utxos_to_recover { @@ -227,7 +220,7 @@ where ); } - Ok(()) + Ok(num_recovered) } fn check_unspent_utxo_and_store( @@ -265,9 +258,17 @@ where else { // The output could be burnt, frozen, or otherwise invalid. If we already have it in the db, update its // status, if not, do nothing. - outputs_api + let has_utxo = outputs_api .update_utxo_status(&address, None, None, Some(found.is_frozen)) - .optional()?; + .optional()? + .is_some(); + + if has_utxo { + self.notify(UtxoRecoveredEvent { + address: UtxoAddress::new(resource_address, found.id), + account_address: found.account_addr, + }); + } self.sdk.store().with_write_tx(|tx| { tx.utxo_process_queue_remove_item(resource_address, found.output.tag, found.output.output.public_nonce) })?; @@ -281,8 +282,9 @@ where tx.utxo_process_queue_remove_item(resource_address, found.output.tag, found.output.output.public_nonce) })?; - self.publish_event(UtxoRecoveryEvent::UtxoRecovered { - utxo_address: UtxoAddress::new(resource_address, found.id), + self.notify(UtxoRecoveredEvent { + address: UtxoAddress::new(resource_address, found.id), + account_address: found.account_addr, }); info!(target: LOG_TARGET, "💰️ Recovered stealth output {} for account {}", address, keys.account_public_key); Ok(true) diff --git a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs index 0c5c1cf077..4401af93c2 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs @@ -185,10 +185,15 @@ where { info!(target: LOG_TARGET, "🔍 Scanning for UTXOs for {}", task); let account = sdk.accounts_api().get_account_by_address(&task.account_address)?; - UtxoScanner::new(sdk) - .scan_and_enqueue_utxos(&account, &task.resource_address, ¬ify_tx) + let num_found = UtxoScanner::new(sdk) + .scan_and_enqueue_utxos(&account, &task.resource_address) .await?; + // UTXOs were found, notify the Utxo recovery worker that there is work to do + if num_found > 0 { + let _ = notify_tx.send(()); + } + Ok(()) } diff --git a/crates/wallet/storage_sqlite/src/writer.rs b/crates/wallet/storage_sqlite/src/writer.rs index 7056523d6b..7fe2095748 100644 --- a/crates/wallet/storage_sqlite/src/writer.rs +++ b/crates/wallet/storage_sqlite/src/writer.rs @@ -584,11 +584,22 @@ impl WalletStoreWriter for WriteTransaction<'_> { .map_err(|e| WalletStorageError::general("accounts_update", e))?; if num_rows == 0 { - return Err(WalletStorageError::NotFound { - operation: "accounts_update", - entity: "account".to_string(), - key: address.to_string(), - }); + // Check if the account exists, because this could have been an update that didnt change anything + // (rows_affected = 0) + let exists = accounts::table + .filter(accounts::address.eq(address.to_string())) + .limit(1) + .count() + .get_result::(self.connection()) + .map_err(|e| WalletStorageError::general("accounts_update", e))?; + + if exists == 0 { + return Err(WalletStorageError::NotFound { + operation: "accounts_update", + entity: "account".to_string(), + key: address.to_string(), + }); + } } Ok(()) From 75b664b573f814135011884b37e93e5bad41f0ec Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 6 Oct 2025 14:10:30 +0400 Subject: [PATCH 4/7] increase salt length to 16 for general purpose encryption --- crates/wallet/crypto/src/encryption.rs | 14 +++++++------- .../migrations/2023-02-08-122514_initial/up.sql | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/wallet/crypto/src/encryption.rs b/crates/wallet/crypto/src/encryption.rs index cbcf1221f4..e3fce3a8ec 100644 --- a/crates/wallet/crypto/src/encryption.rs +++ b/crates/wallet/crypto/src/encryption.rs @@ -20,7 +20,7 @@ const ENCRYPTED_DATA_TAG: &[u8] = b"TARI_WALLET_EXTEND_NONCE_VARIANT"; // Fixed sizes (all in bytes) const TAG_SIZE: usize = size_of::(); -const SALT_LENGTH: usize = 5; +const SALT_LENGTH: usize = 16; const ARGON2_SALT_BYTES: usize = 16; const ENCRYPTION_KEY_BYTES_LEN: usize = 32; const MAC_KEY_BYTE_LEN: usize = 32; @@ -44,21 +44,21 @@ pub fn decrypt_with_password(cipher_text: &[u8], passphrase: &[u8]) -> Result Result Date: Mon, 6 Oct 2025 14:34:00 +0400 Subject: [PATCH 5/7] add utxo spent event --- applications/tari_walletd/src/services/mod.rs | 2 +- .../src/account_monitor/monitor.rs | 3 +- crates/wallet/sdk_services/src/events.rs | 12 +++++ .../src/transaction_service/service.rs | 3 +- .../sdk_services/src/utxo_scanner/scanner.rs | 28 ++++++++---- .../src/utxo_scanner/scanner_round.rs | 45 +++++++++++++++---- .../sdk_services/src/utxo_scanner/worker.rs | 28 +++++++----- 7 files changed, 91 insertions(+), 30 deletions(-) diff --git a/applications/tari_walletd/src/services/mod.rs b/applications/tari_walletd/src/services/mod.rs index 677487f890..9c28e1f2b8 100644 --- a/applications/tari_walletd/src/services/mod.rs +++ b/applications/tari_walletd/src/services/mod.rs @@ -48,7 +48,7 @@ where let template_monitor = TemplateMonitor::new(notify.clone(), wallet_sdk.clone(), shutdown_signal.clone()); let template_monitor_join_handle = tokio::spawn(template_monitor.run()); - let utxo_scanner = StealthUtxoScannerWorker::new(wallet_sdk.clone()); + let utxo_scanner = StealthUtxoScannerWorker::new(wallet_sdk.clone(), notify.clone()); let (utxo_scanner_join_handle, utxo_scanner_handle) = utxo_scanner.spawn(); let utxo_recovery_join_handle = { diff --git a/crates/wallet/sdk_services/src/account_monitor/monitor.rs b/crates/wallet/sdk_services/src/account_monitor/monitor.rs index 7edc13c5cf..12fc6bd762 100644 --- a/crates/wallet/sdk_services/src/account_monitor/monitor.rs +++ b/crates/wallet/sdk_services/src/account_monitor/monitor.rs @@ -232,7 +232,8 @@ where WalletEvent::AuthLoginRequest(_) | WalletEvent::UtxoRecoveryStarted(_) | WalletEvent::UtxoRecovered(_) | - WalletEvent::UtxoRecoveryCompleted(_) => {}, + WalletEvent::UtxoRecoveryCompleted(_) | + WalletEvent::UtxoSpent(_) => {}, } Ok(()) } diff --git a/crates/wallet/sdk_services/src/events.rs b/crates/wallet/sdk_services/src/events.rs index db5683262d..5dbb2156ec 100644 --- a/crates/wallet/sdk_services/src/events.rs +++ b/crates/wallet/sdk_services/src/events.rs @@ -17,6 +17,7 @@ pub enum WalletEvent { UtxoRecoveryStarted(UtxoRecoveryStartedEvent), UtxoRecovered(UtxoRecoveredEvent), UtxoRecoveryCompleted(UtxoRecoveryCompletedEvent), + UtxoSpent(UtxoSpentEvent), } impl From for WalletEvent { @@ -73,6 +74,12 @@ impl From for WalletEvent { } } +impl From for WalletEvent { + fn from(value: UtxoSpentEvent) -> Self { + Self::UtxoSpent(value) + } +} + #[derive(Debug, Clone)] pub struct TransactionSubmittedEvent { pub transaction_id: TransactionId, @@ -126,3 +133,8 @@ pub struct UtxoRecoveryCompletedEvent { pub round_id: usize, pub num_recovered: usize, } + +#[derive(Debug, Clone)] +pub struct UtxoSpentEvent { + pub address: UtxoAddress, +} diff --git a/crates/wallet/sdk_services/src/transaction_service/service.rs b/crates/wallet/sdk_services/src/transaction_service/service.rs index f53b2146a7..73faa11577 100644 --- a/crates/wallet/sdk_services/src/transaction_service/service.rs +++ b/crates/wallet/sdk_services/src/transaction_service/service.rs @@ -309,7 +309,8 @@ where WalletEvent::AccountCreatedOnChain(_) | WalletEvent::UtxoRecoveryStarted(_) | WalletEvent::UtxoRecovered(_) | - WalletEvent::UtxoRecoveryCompleted(_) => {}, + WalletEvent::UtxoRecoveryCompleted(_) | + WalletEvent::UtxoSpent(_) => {}, } Ok(()) } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs index a3995e5900..9a0c320725 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner.rs @@ -10,10 +10,15 @@ use tari_ootle_wallet_sdk::{ }; use tari_template_lib::models::ResourceAddress; -use crate::utxo_scanner::{StealthScannerApiError, UtxoScannerRound}; +use crate::{ + events::WalletEvent, + notify::Notify, + utxo_scanner::{StealthScannerApiError, UtxoScanRoundStats, UtxoScannerRound}, +}; pub struct UtxoScanner { sdk: WalletSdk, + wallet_notify: Notify, } impl UtxoScanner @@ -22,15 +27,15 @@ where TNetworkInterface: WalletNetworkInterface, TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { - pub fn new(sdk: WalletSdk) -> Self { - Self { sdk } + pub fn new(sdk: WalletSdk, wallet_notify: Notify) -> Self { + Self { sdk, wallet_notify } } pub async fn scan_and_enqueue_utxos( &self, account: &AccountWithAddress, resource_address: &ResourceAddress, - ) -> Result { + ) -> Result { let network = self.sdk.config_api().get_network()?; let view_key = self @@ -38,9 +43,16 @@ where .key_manager_api() .get_view_only_key(account.view_only_key_id())?; - let mut scanner_round = UtxoScannerRound::new(network, &self.sdk, account, &view_key, resource_address); - let num_found = scanner_round.scan_for_utxo_updates().await?; - - Ok(num_found) + let mut scanner_round = UtxoScannerRound::new( + network, + &self.sdk, + account, + &view_key, + resource_address, + &self.wallet_notify, + ); + scanner_round.scan_for_utxo_updates().await?; + + Ok(scanner_round.into_stats()) } } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs index 1a30459de7..38cd723857 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs @@ -19,9 +19,13 @@ use tari_ootle_wallet_sdk::{ storage::{WalletStorageError, WalletStore, WalletStoreReader, WalletStoreWriter}, WalletSdk, }; -use tari_template_lib::models::{ComponentAddress, ResourceAddress}; +use tari_template_lib::models::{ComponentAddress, ResourceAddress, UtxoAddress}; -use crate::utxo_scanner::StealthScannerApiError; +use crate::{ + events::{UtxoSpentEvent, WalletEvent}, + notify::Notify, + utxo_scanner::StealthScannerApiError, +}; const LOG_TARGET: &str = "tari::ootle::wallet_services::scanner_round"; // TODO: either fetch num preshards from the network or we should hardcode it to a single value for all apps @@ -34,10 +38,12 @@ pub struct UtxoScannerRound<'a, TStore, TNetworkInterface> { resource_address: &'a ResourceAddress, sdk: &'a WalletSdk, + stats: UtxoScanRoundStats, shard_state_versions_to_set: HashMap, utxos_to_recover: Vec<(ComponentAddress, UtxoUnspent)>, utxos_to_spend: Vec, + notify: &'a Notify, } impl<'a, TStore, TNetworkInterface> UtxoScannerRound<'a, TStore, TNetworkInterface> @@ -52,6 +58,7 @@ where account: &'a AccountWithAddress, view_key: &'a Key, resource_address: &'a ResourceAddress, + notify: &'a Notify, ) -> Self { Self { network, @@ -62,21 +69,27 @@ where shard_state_versions_to_set: HashMap::new(), utxos_to_recover: Vec::new(), utxos_to_spend: Vec::new(), + notify, + stats: UtxoScanRoundStats::default(), } } - pub async fn scan_for_utxo_updates(&mut self) -> Result { - let mut num_found = 0; + pub async fn scan_for_utxo_updates(&mut self) -> Result<(), StealthScannerApiError> { loop { if !self.scan().await? { break; } - num_found += 1; + self.stats.num_recovered += 1; } - Ok(num_found) + Ok(()) + } + + pub fn into_stats(self) -> UtxoScanRoundStats { + self.stats } + #[allow(clippy::too_many_lines)] async fn scan(&mut self) -> Result { let mut shard_state_versions = self .sdk @@ -123,7 +136,6 @@ where } let num_received = response.shard_updates.len(); - let mut num_spent = 0; for (shard, update_set) in response.shard_updates { self.shard_state_versions_to_set @@ -163,10 +175,16 @@ where // Atomically persist all changes from this round let num_recovered = self.utxos_to_recover.len(); + self.stats.num_received += num_received; + self.stats.num_recovered += num_recovered; + let mut num_spent = 0; self.sdk.store().with_write_tx(|tx| { // Mark UTXOs as spent (if they exist) for spent in self.utxos_to_spend.drain(..) { - if Self::spend(tx, self.resource_address, spent)? { + if Self::spend(tx, self.resource_address, &spent)? { + self.notify.notify(UtxoSpentEvent { + address: UtxoAddress::new(*self.resource_address, spent.id), + }); num_spent += 1; } } @@ -191,6 +209,8 @@ where num_spent ); + self.stats.num_spent += num_spent; + Ok(true) } @@ -231,7 +251,7 @@ where fn spend( tx: &mut TStore::WriteTransaction<'_>, resource_address: &ResourceAddress, - spent: UtxoSpent, + spent: &UtxoSpent, ) -> Result { match tx .stealth_outputs_mark_as_spent(resource_address, &spent.id) @@ -251,3 +271,10 @@ where } } } + +#[derive(Debug, Clone, Default)] +pub struct UtxoScanRoundStats { + pub num_received: usize, + pub num_recovered: usize, + pub num_spent: usize, +} diff --git a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs index 4401af93c2..5412f06a16 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs @@ -22,7 +22,7 @@ use tokio::{ task::JoinHandle, }; -use crate::utxo_scanner::UtxoScanner; +use crate::{events::WalletEvent, notify::Notify, utxo_scanner::UtxoScanner}; const LOG_TARGET: &str = "tari::ootle::wallet_services::stealth_utxo_scanner"; @@ -59,9 +59,9 @@ where TNetworkInterface: WalletNetworkInterface + Clone + Send + Sync + 'static, TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { - pub fn new(sdk: WalletSdk) -> Self { + pub fn new(sdk: WalletSdk, notify: Notify) -> Self { Self { - scanner: StealthUtxoScanner::new(sdk.clone()), + scanner: StealthUtxoScanner::new(sdk.clone(), notify), } } @@ -108,6 +108,7 @@ pub struct StealthUtxoScanner { in_progress_work: futures_bounded::FuturesMap, sdk: WalletSdk, notify_tx: watch::Sender<()>, + wallet_notify: Notify, } impl StealthUtxoScanner @@ -116,12 +117,13 @@ where TNetworkInterface: WalletNetworkInterface + Clone + Send + Sync + 'static, TNetworkInterface::Error: IsNotFoundError + StatusResponseError, { - pub(self) fn new(sdk: WalletSdk) -> Self { + pub(self) fn new(sdk: WalletSdk, wallet_events: Notify) -> Self { let (notify_tx, _) = watch::channel::<()>(()); Self { in_progress_work: futures_bounded::FuturesMap::new(Duration::from_secs(300), MAX_CONCURRENT_SCANS), sdk, notify_tx, + wallet_notify: wallet_events, } } @@ -136,10 +138,15 @@ where info!(target: LOG_TARGET, "🔍️ Scan for {} is already in progress, ignoring request", task); return; } - match self - .in_progress_work - .try_push(task, do_work(self.sdk.clone(), self.notify_tx.clone(), task)) - { + match self.in_progress_work.try_push( + task, + do_work( + self.sdk.clone(), + self.notify_tx.clone(), + task, + self.wallet_notify.clone(), + ), + ) { Ok(()) => {}, Err(PushError::BeyondCapacity(_)) => { warn!( @@ -177,6 +184,7 @@ async fn do_work( sdk: WalletSdk, notify_tx: watch::Sender<()>, task: UtxoScanRequest, + wallet_notify: Notify, ) -> ScanResult where TStore: WalletStore, @@ -185,12 +193,12 @@ where { info!(target: LOG_TARGET, "🔍 Scanning for UTXOs for {}", task); let account = sdk.accounts_api().get_account_by_address(&task.account_address)?; - let num_found = UtxoScanner::new(sdk) + let stats = UtxoScanner::new(sdk, wallet_notify) .scan_and_enqueue_utxos(&account, &task.resource_address) .await?; // UTXOs were found, notify the Utxo recovery worker that there is work to do - if num_found > 0 { + if stats.num_recovered > 0 { let _ = notify_tx.send(()); } From a4569b3c0cc375c986aeb8a0f2a50d33758f5be4 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 6 Oct 2025 14:46:15 +0400 Subject: [PATCH 6/7] refresh all accounts (not just first 100) --- .../tari_walletd/src/handlers/accounts.rs | 14 ++++-- crates/wallet/sdk/src/apis/accounts.rs | 14 ++---- .../wallet/sdk/src/apis/stealth_transfer.rs | 14 +++--- crates/wallet/sdk/src/models/account.rs | 2 +- crates/wallet/sdk/src/storage.rs | 2 +- .../src/account_monitor/monitor.rs | 43 ++++++++++++------- crates/wallet/storage_sqlite/src/reader.rs | 2 +- 7 files changed, 50 insertions(+), 41 deletions(-) diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index ba745dee84..b893dbe15b 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -244,14 +244,20 @@ pub async fn handle_list( ) -> Result { context.check_auth(token, &[JrpcPermission::Admin])?; let sdk = context.wallet_sdk(); - let accounts = sdk.accounts_api().get_many(req.offset, req.limit)?; - let total = sdk.accounts_api().count()?; + let limit = usize::try_from(req.limit) + .map_err(|e| invalid_params("limit", Some(&format!("limit overflowed usize: {}", e))))?; + let offset = usize::try_from(req.offset) + .map_err(|e| invalid_params("offset", Some(&format!("offset overflowed usize: {}", e))))?; + let accounts_api = sdk.accounts_api(); + let accounts = accounts_api.get_many(offset, limit)?; + let total = accounts_api.count()?; let accounts = accounts .into_iter() .map(|a| { + let address = accounts_api.get_address_for_account(&a)?; Ok(AccountInfo { - account: a.account, - address: a.address, + account: a, + address: address.to_byte_type(), }) }) .collect::>()?; diff --git a/crates/wallet/sdk/src/apis/accounts.rs b/crates/wallet/sdk/src/apis/accounts.rs index 1148baebf5..e6900094e8 100644 --- a/crates/wallet/sdk/src/apis/accounts.rs +++ b/crates/wallet/sdk/src/apis/accounts.rs @@ -159,17 +159,9 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor }) } - pub fn get_many(&self, offset: u64, limit: u64) -> Result, AccountsApiError> { + pub fn get_many(&self, offset: usize, limit: usize) -> Result, AccountsApiError> { let accounts = self.store.with_read_tx(|tx| tx.accounts_get_many(offset, limit))?; - accounts - .into_iter() - .map(|a| { - self.get_address_for_account(&a).map(|address| AccountWithAddress { - account: a, - address: address.to_byte_type(), - }) - }) - .collect() + Ok(accounts) } pub fn count(&self) -> Result { @@ -233,7 +225,7 @@ impl<'a, TStore: WalletStore, TNetworkInterface> AccountsApi<'a, TStore, TNetwor }) } - fn get_address_for_account(&self, account: &Account) -> Result { + pub fn get_address_for_account(&self, account: &Account) -> Result { let view_only_key = match account.view_only_key_id { KeyId::Derived { index } => { let view_only_key = self.key_manager_api.derive_view_only_key(index)?; diff --git a/crates/wallet/sdk/src/apis/stealth_transfer.rs b/crates/wallet/sdk/src/apis/stealth_transfer.rs index 80a5640973..4dcf5426e6 100644 --- a/crates/wallet/sdk/src/apis/stealth_transfer.rs +++ b/crates/wallet/sdk/src/apis/stealth_transfer.rs @@ -117,7 +117,7 @@ where let maybe_src_vault = self .accounts_api - .get_vault_by_resource(owner_account.address(), &resource_address) + .get_vault_by_resource(owner_account.component_address(), &resource_address) .optional()?; let available_revealed_funds = maybe_src_vault @@ -128,7 +128,7 @@ where match input_selection { ConfidentialTransferInputSelection::ConfidentialOnly => { let (input_models, total_locked) = self.outputs_api.lock_outputs_for_at_least_amount( - owner_account.address(), + owner_account.component_address(), &resource_address, lock_id, spend_amount, @@ -161,7 +161,7 @@ where details: format!( "No vault found for resource {} in account {}", resource_address, - owner_account.address() + owner_account.component_address() ), })?; @@ -215,14 +215,14 @@ where "PreferRevealed: No vault found for resource {} in account {}. Need to spend {} revealed \ funds", resource_address, - owner_account.address(), + owner_account.component_address(), revealed_to_spend ), }); } let (inputs, _) = self.outputs_api.lock_outputs_for_at_least_amount( - owner_account.address(), + owner_account.component_address(), &resource_address, lock_id, utxo_amount_to_spend, @@ -276,7 +276,7 @@ where ConfidentialTransferInputSelection::PreferConfidential => { let lock_id = self.outputs_api.create_lock()?; let (blinded_inputs, blinded_amount_locked) = self.outputs_api.lock_outputs_until_partial_amount( - owner_account.address(), + owner_account.component_address(), &resource_address, spend_amount, lock_id, @@ -306,7 +306,7 @@ where "PreferConfidential: No vault found for resource {} in account {}. Need to spend \ {} revealed funds", resource_address, - owner_account.address(), + owner_account.component_address(), revealed_to_spend ), }); diff --git a/crates/wallet/sdk/src/models/account.rs b/crates/wallet/sdk/src/models/account.rs index 7ef12a9c35..2037b3aaed 100644 --- a/crates/wallet/sdk/src/models/account.rs +++ b/crates/wallet/sdk/src/models/account.rs @@ -22,7 +22,7 @@ pub struct Account { } impl Account { - pub fn address(&self) -> &ComponentAddress { + pub fn component_address(&self) -> &ComponentAddress { &self.component_address } diff --git a/crates/wallet/sdk/src/storage.rs b/crates/wallet/sdk/src/storage.rs index c6d597198c..a8e9666e6b 100644 --- a/crates/wallet/sdk/src/storage.rs +++ b/crates/wallet/sdk/src/storage.rs @@ -179,7 +179,7 @@ pub trait WalletStoreReader { fn substates_get_children(&mut self, parent: &SubstateId) -> Result, WalletStorageError>; // Accounts fn accounts_get(&mut self, address: &ComponentAddress) -> Result; - fn accounts_get_many(&mut self, offset: u64, limit: u64) -> Result, WalletStorageError>; + fn accounts_get_many(&mut self, offset: usize, limit: usize) -> Result, WalletStorageError>; fn accounts_get_default(&mut self) -> Result; fn accounts_count(&mut self) -> Result; fn accounts_get_by_name(&mut self, name: &str) -> Result; diff --git a/crates/wallet/sdk_services/src/account_monitor/monitor.rs b/crates/wallet/sdk_services/src/account_monitor/monitor.rs index 12fc6bd762..0c214c2101 100644 --- a/crates/wallet/sdk_services/src/account_monitor/monitor.rs +++ b/crates/wallet/sdk_services/src/account_monitor/monitor.rs @@ -145,26 +145,37 @@ where async fn refresh_all_accounts(&self) -> Result<(), AccountMonitorError> { let accounts_api = self.wallet_sdk.accounts_api(); - // TODO: There could be more than 100 accounts - let accounts = accounts_api.get_many(0, 100)?; - for account in accounts { - let is_updated = self.scanner.refresh_account(*account.component_address()).await?; - if self.enable_periodic_scanning_with_utxos { - self.refresh_stealth_utxos(*account.component_address()).await?; + const PAGE_SIZE: usize = 10; + let mut offset = 0; + loop { + let accounts = accounts_api.get_many(offset, PAGE_SIZE)?; + if accounts.is_empty() { + break; } + for account in &accounts { + let is_updated = self.scanner.refresh_account(*account.component_address()).await?; + if self.enable_periodic_scanning_with_utxos { + self.refresh_stealth_utxos(*account.component_address()).await?; + } - if is_updated { - info!( - target: LOG_TARGET, - "👁️‍🗨️ Account {} has been updated", account - ); - } else { - info!( - target: LOG_TARGET, - "👁️‍🗨️ Account {} is up to date", account - ); + if is_updated { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} has been updated", account + ); + } else { + info!( + target: LOG_TARGET, + "👁️‍🗨️ Account {} is up to date", account + ); + } + } + offset += accounts.len(); + if accounts.len() < PAGE_SIZE { + break; } } + Ok(()) } diff --git a/crates/wallet/storage_sqlite/src/reader.rs b/crates/wallet/storage_sqlite/src/reader.rs index d64e84085a..0fb5ec090a 100644 --- a/crates/wallet/storage_sqlite/src/reader.rs +++ b/crates/wallet/storage_sqlite/src/reader.rs @@ -396,7 +396,7 @@ impl WalletStoreReader for ReadTransaction<'_> { Ok(account) } - fn accounts_get_many(&mut self, offset: u64, limit: u64) -> Result, WalletStorageError> { + fn accounts_get_many(&mut self, offset: usize, limit: usize) -> Result, WalletStorageError> { use crate::schema::accounts; let rows = accounts::table From b9d1f88b2ff834fad74459a7ffff9ea1ad8fca64 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 6 Oct 2025 14:49:22 +0400 Subject: [PATCH 7/7] rename field --- .../sdk_services/src/utxo_scanner/scanner_round.rs | 12 +++++++----- .../wallet/sdk_services/src/utxo_scanner/worker.rs | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs index 38cd723857..7d4f01f6bd 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/scanner_round.rs @@ -76,10 +76,9 @@ where pub async fn scan_for_utxo_updates(&mut self) -> Result<(), StealthScannerApiError> { loop { - if !self.scan().await? { + if !self.process_next_batch().await? { break; } - self.stats.num_recovered += 1; } Ok(()) @@ -90,7 +89,7 @@ where } #[allow(clippy::too_many_lines)] - async fn scan(&mut self) -> Result { + async fn process_next_batch(&mut self) -> Result { let mut shard_state_versions = self .sdk .store() @@ -176,7 +175,7 @@ where // Atomically persist all changes from this round let num_recovered = self.utxos_to_recover.len(); self.stats.num_received += num_received; - self.stats.num_recovered += num_recovered; + self.stats.num_potential_recoveries += num_recovered; let mut num_spent = 0; self.sdk.store().with_write_tx(|tx| { // Mark UTXOs as spent (if they exist) @@ -274,7 +273,10 @@ where #[derive(Debug, Clone, Default)] pub struct UtxoScanRoundStats { + /// Number of UTXO updates received from the network pub num_received: usize, - pub num_recovered: usize, + /// Number of UTXOs that matched the tag and were queued for recovery + pub num_potential_recoveries: usize, + /// Number of UTXOs that were marked as spent pub num_spent: usize, } diff --git a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs index 5412f06a16..18ea1a7aa5 100644 --- a/crates/wallet/sdk_services/src/utxo_scanner/worker.rs +++ b/crates/wallet/sdk_services/src/utxo_scanner/worker.rs @@ -198,7 +198,7 @@ where .await?; // UTXOs were found, notify the Utxo recovery worker that there is work to do - if stats.num_recovered > 0 { + if stats.num_potential_recoveries > 0 { let _ = notify_tx.send(()); }