From 437626260c9cecdf39a3165cb4e89ac0703b0336 Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Fri, 7 Nov 2025 16:40:11 +0400 Subject: [PATCH 1/2] fix(wallet)!: improved input selection algo --- .../tari_wallet_cli/src/command/proof.rs | 6 +- .../tari_walletd/src/handlers/accounts.rs | 29 +- .../tari_walletd/src/handlers/confidential.rs | 60 ++- .../src/handlers/stealth_utxos.rs | 2 +- applications/tari_walletd/src/jrpc_server.rs | 1 - .../src/services/api/hooks/useAccounts.ts | 2 +- .../ConfidentialCreateOutputProofRequest.ts | 3 +- .../ProofsGenerateRequest.ts | 2 +- .../wallet-daemon-client/StealthTransfer.ts | 2 +- .../wallet-daemon-client/TransferOutput.ts | 2 +- .../wallet_daemon_client/src/index.ts | 6 +- clients/wallet_daemon_client/src/types.rs | 11 +- crates/engine/tests/burn.rs | 2 +- crates/engine/tests/confidential.rs | 7 +- crates/engine/tests/recall.rs | 2 +- crates/engine/tests/stealth.rs | 34 +- crates/engine_types/src/crypto/helpers.rs | 5 + .../template_lib_types/src/amount/amount.rs | 63 ++- .../src/support/confidential.rs | 63 +-- .../src/support/stealth.rs | 34 +- crates/transaction/Cargo.toml | 5 +- crates/wallet/crypto/src/bullet_proof.rs | 5 +- crates/wallet/crypto/src/confidential.rs | 27 +- crates/wallet/crypto/src/encrypted_data.rs | 13 +- crates/wallet/crypto/src/stealth.rs | 20 +- .../wallet/crypto/src/unblinded_statement.rs | 22 +- .../crypto/src/viewable_balance_proof.rs | 9 +- .../tests/stealth_transfer_statement.rs | 13 +- .../crypto/tests/viewable_balance_proof.rs | 10 +- crates/wallet/sdk/Cargo.toml | 12 +- .../sdk/src/apis/confidential_crypto.rs | 2 +- .../sdk/src/apis/confidential_outputs.rs | 4 +- .../sdk/src/apis/confidential_transfer.rs | 38 +- crates/wallet/sdk/src/apis/stealth_crypto.rs | 2 +- crates/wallet/sdk/src/apis/stealth_outputs.rs | 176 +++++--- .../sdk/src/apis/stealth_transfer/api.rs | 52 ++- .../sdk/src/apis/stealth_transfer/params.rs | 15 +- .../sdk/src/apis/stealth_transfer/types.rs | 6 +- .../input_selection/branch_and_bound.rs | 400 ++++++++++++++++++ .../sdk/src/models/input_selection/mod.rs | 12 + crates/wallet/sdk/src/models/mod.rs | 1 + .../wallet/sdk/src/models/stealth_output.rs | 26 +- crates/wallet/sdk/src/storage/reader.rs | 11 +- crates/wallet/sdk/src/storage/writer.rs | 9 +- .../2023-02-08-122514_initial/up.sql | 2 +- .../src/models/stealth_output.rs | 48 ++- crates/wallet/storage_sqlite/src/reader.rs | 47 +- crates/wallet/storage_sqlite/src/schema.rs | 2 +- crates/wallet/storage_sqlite/src/writer.rs | 48 ++- integration_tests/src/wallet_daemon_client.rs | 2 +- .../tests/steps/wallet_daemon.rs | 4 +- utilities/traffic-sim/src/sim.rs | 4 +- 52 files changed, 1014 insertions(+), 369 deletions(-) create mode 100644 crates/wallet/sdk/src/models/input_selection/branch_and_bound.rs create mode 100644 crates/wallet/sdk/src/models/input_selection/mod.rs diff --git a/applications/tari_wallet_cli/src/command/proof.rs b/applications/tari_wallet_cli/src/command/proof.rs index dcc9af4286..4e46494690 100644 --- a/applications/tari_wallet_cli/src/command/proof.rs +++ b/applications/tari_wallet_cli/src/command/proof.rs @@ -35,7 +35,7 @@ pub enum ProofsSubcommand { #[derive(Debug, Args, Clone)] pub struct GenerateArgs { - pub amount: i64, + pub amount: u64, #[clap(short = 'o', long)] pub output_type: OutputType, } @@ -66,9 +66,7 @@ impl ProofsSubcommand { match self { Generate(args) => { let resp = client - .create_confidential_output_proof(ConfidentialCreateOutputProofRequest { - amount: args.amount.into(), - }) + .create_confidential_output_proof(ConfidentialCreateOutputProofRequest { amount: args.amount }) .await?; match args.output_type { diff --git a/applications/tari_walletd/src/handlers/accounts.rs b/applications/tari_walletd/src/handlers/accounts.rs index 9a4d8f3531..2bd10e29ca 100644 --- a/applications/tari_walletd/src/handlers/accounts.rs +++ b/applications/tari_walletd/src/handlers/accounts.rs @@ -82,7 +82,6 @@ use tokio::task; use super::context::HandlerContext; use crate::{ handlers::helpers::{ - application_error, general_error, get_account, get_account_by_key_index, @@ -95,7 +94,6 @@ use crate::{ wait_for_result, wait_for_result_and_account, }, - jrpc_server::ApplicationErrorCode, DEFAULT_FEE, }; @@ -295,10 +293,8 @@ pub async fn handle_get_balances( let confidential_balance = if vault.resource_type.is_stealth() { let stealth_balance = stealth_outputs .iter() - .filter(|o| { - o.owner_account == *account.component_address() && o.resource_address == vault.resource_address - }) - .map(|o| o.value) + .filter(|o| o.resource_address == vault.resource_address) + .map(|o| Amount::from(o.value)) .sum::(); if stealth_balance.is_positive() { @@ -328,8 +324,8 @@ pub async fn handle_get_balances( // NOTE: indexemap used to ensure a consistent order (HashMap causes UI to randomly switch positions for multiple stealth resources) .fold(IndexMap::new(), |mut acc, o| { acc.entry(o.resource_address) - .and_modify(|v| *v += o.value) - .or_insert(o.value); + .and_modify(|v| *v += Amount::from(o.value)) + .or_insert(Amount::from(o.value)); acc }); @@ -479,22 +475,13 @@ pub async fn handle_claim_burn( let final_amount = decrypted .value() - .checked_sub_positive(max_fee.into()) + .checked_sub(max_fee) .ok_or_else(|| invalid_params("max_fee", Some("more fees paid than claimed amount")))?; - if final_amount.is_zero() { + if final_amount == 0 { return Err(invalid_params("max_fee", Some("fee equals or exceeds claimed amount"))); } - let final_amount_u64 = final_amount.to_u64_checked().ok_or_else(|| { - // NOTE: this can never be anywhere close to this large because this would be more than the total supply of XTM - // for thousands of years - application_error( - ApplicationErrorCode::NotImplemented, - format!("Amount to spend {final_amount} is too large and not currently supported"), - ) - })?; - let (nonce, output_public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); 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(); @@ -505,7 +492,7 @@ pub async fn handle_claim_burn( // 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, + final_amount, &mask.key, &view_only_public_key, &nonce, @@ -1167,7 +1154,7 @@ pub async fn handle_create_stealth_transfer_statement( let outputs = req .outputs .iter() - .filter(|o| o.blinded_amount.is_positive()) + .filter(|o| o.blinded_amount > 0) .map(TryInto::try_into) .collect::, _>>()?; diff --git a/applications/tari_walletd/src/handlers/confidential.rs b/applications/tari_walletd/src/handlers/confidential.rs index cf07045d1d..6ba50ad0c5 100644 --- a/applications/tari_walletd/src/handlers/confidential.rs +++ b/applications/tari_walletd/src/handlers/confidential.rs @@ -51,10 +51,10 @@ pub async fn handle_create_transfer_proof( let sdk = context.wallet_sdk(); context.check_auth(token, &[JrpcPermission::Admin])?; - if req.amount.is_negative() || req.reveal_amount.is_negative() { + if req.reveal_amount.is_negative() { return Err(invalid_request(format!( - "Amount to send must be positive. Amount = {}, Revealed = {}", - req.amount, req.reveal_amount + "Amount to send must be positive. Revealed amount was {}", + req.reveal_amount ))); } @@ -67,12 +67,14 @@ pub async fn handle_create_transfer_proof( .get_vault_by_resource(account.component_address(), &req.resource_address)?; let lock = sdk.locks_api().create_lock_with_timeout(Duration::from_secs(5 * 60))?; - let amount_to_transfer = req.amount.checked_add_positive(req.reveal_amount).ok_or_else(|| { - invalid_request(format!( - "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", - req.amount, req.reveal_amount - )) - })?; + let amount_to_transfer = Amount::from(req.confidential_amount) + .checked_add(req.reveal_amount) + .ok_or_else(|| { + invalid_request(format!( + "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", + req.confidential_amount, req.reveal_amount + )) + })?; // Lock inputs we're going to spend let (inputs, total_input_value) = sdk.confidential_outputs_api() @@ -93,15 +95,8 @@ pub async fn handle_create_transfer_proof( let output_mask = sdk.key_manager_api().next_key(KeyBranch::ConfidentialMask)?; let (_, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); - let amount_u64 = req.amount.to_u64_checked().ok_or_else(|| { - invalid_request(format!( - "Amount to send must be a non-negative integer that does not exceed u64::MAX. Amount = {}", - req.amount - )) - })?; - let encrypted_data = sdk.confidential_crypto_api().encrypt_value_and_mask( - amount_u64, + req.confidential_amount, &output_mask.key, &public_nonce, &account_key.secret, @@ -118,7 +113,7 @@ pub async fn handle_create_transfer_proof( })?; let output_statement = UnblindedOutputWitness { - amount: req.amount, + amount: req.confidential_amount, mask: output_mask.key, sender_public_nonce: public_nonce, minimum_value_promise: 0, @@ -126,16 +121,18 @@ pub async fn handle_create_transfer_proof( resource_view_key: resource_view_key.clone(), }; - let spend_amount = req.amount.checked_sub_positive(req.reveal_amount).ok_or_else(|| { - invalid_request(format!( - "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", - req.amount, req.reveal_amount - )) - })?; + let spend_amount = Amount::from(req.confidential_amount) + .checked_sub(req.reveal_amount) + .ok_or_else(|| { + invalid_request(format!( + "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", + req.confidential_amount, req.reveal_amount + )) + })?; let change_amount = total_input_value.checked_sub_positive(spend_amount).ok_or_else(|| { invalid_request(format!( "Insufficient funds to send {}. Total input value = {}", - req.amount, total_input_value + req.confidential_amount, total_input_value )) })?; let change_amount_u64 = change_amount.to_u64_checked().ok_or_else(|| { @@ -175,7 +172,7 @@ pub async fn handle_create_transfer_proof( })?; Some(UnblindedOutputWitness { - amount: change_amount, + amount: change_amount_u64, mask: change_mask.key, sender_public_nonce: public_nonce, encrypted_data, @@ -192,7 +189,7 @@ pub async fn handle_create_transfer_proof( &inputs, // TODO: support for using revealed funds as input for proof generation Amount::zero(), - Some(&output_statement).filter(|o| !o.amount.is_zero()), + Some(&output_statement).filter(|o| o.amount > 0), req.reveal_amount, maybe_change_statement.as_ref(), Amount::zero(), @@ -278,17 +275,10 @@ pub async fn handle_create_output_proof( let sdk = context.wallet_sdk(); context.check_auth(token, &[JrpcPermission::Admin])?; - let Some(amount) = req.amount.to_u64_checked() else { - return Err(invalid_params( - "amount", - Some("must be positive and less than u64::MAX"), - )); - }; - let output_mask = sdk.key_manager_api().next_key(KeyBranch::ConfidentialMask)?; let (_, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); let encrypted_data = sdk.confidential_crypto_api().encrypt_value_and_mask( - amount, + req.amount, &output_mask.key, &public_nonce, &output_mask.key, diff --git a/applications/tari_walletd/src/handlers/stealth_utxos.rs b/applications/tari_walletd/src/handlers/stealth_utxos.rs index 6e5b43e782..06808da3e1 100644 --- a/applications/tari_walletd/src/handlers/stealth_utxos.rs +++ b/applications/tari_walletd/src/handlers/stealth_utxos.rs @@ -47,7 +47,7 @@ pub async fn handle_list( .into_iter() .map(|o| UtxoInfo { address: o.to_utxo_address(), - value: o.value, + value: o.value.into(), status: o.status, memo: o.memo, is_burnt: o.is_burnt, diff --git a/applications/tari_walletd/src/jrpc_server.rs b/applications/tari_walletd/src/jrpc_server.rs index f645597c42..c7d1ca789e 100644 --- a/applications/tari_walletd/src/jrpc_server.rs +++ b/applications/tari_walletd/src/jrpc_server.rs @@ -285,5 +285,4 @@ pub enum ApplicationErrorCode { InvalidRequest = 400, TransactionRejected = 1000, GeneralError = 500, - NotImplemented = 501, } 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 eec9ee1239..c9354d3ff2 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 @@ -166,7 +166,7 @@ export const useAccountsTransfer = () => { transfers: [ { destination_address: params.destination_address, - blinded_output_amount: params.output_to_revealed ? 0 : params.amount, + blinded_output_amount: params.output_to_revealed ? 0n : BigInt(params.amount), revealed_output_amount: params.output_to_revealed ? params.amount : 0, output_memo: params.output_memo || null, }, diff --git a/bindings/src/types/wallet-daemon-client/ConfidentialCreateOutputProofRequest.ts b/bindings/src/types/wallet-daemon-client/ConfidentialCreateOutputProofRequest.ts index b6c4b65dbd..9a489773bd 100644 --- a/bindings/src/types/wallet-daemon-client/ConfidentialCreateOutputProofRequest.ts +++ b/bindings/src/types/wallet-daemon-client/ConfidentialCreateOutputProofRequest.ts @@ -1,4 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { Amount } from "../Amount"; -export type ConfidentialCreateOutputProofRequest = { amount: Amount }; +export type ConfidentialCreateOutputProofRequest = { amount: bigint }; diff --git a/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts b/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts index 9e73ffc0d0..676a24681a 100644 --- a/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts +++ b/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts @@ -6,7 +6,7 @@ import type { RistrettoPublicKeyBytes } from "../RistrettoPublicKeyBytes"; import type { ComponentAddressOrName } from "./ComponentAddressOrName"; export type ProofsGenerateRequest = { - amount: Amount; + confidential_amount: bigint; reveal_amount: Amount; account: ComponentAddressOrName | null; resource_address: ResourceAddress; diff --git a/bindings/src/types/wallet-daemon-client/StealthTransfer.ts b/bindings/src/types/wallet-daemon-client/StealthTransfer.ts index 47b1e92262..da88384d2f 100644 --- a/bindings/src/types/wallet-daemon-client/StealthTransfer.ts +++ b/bindings/src/types/wallet-daemon-client/StealthTransfer.ts @@ -5,7 +5,7 @@ import type { OotleAddress } from "../OotleAddress"; export type StealthTransfer = { destination_address: OotleAddress; - blinded_output_amount: Amount; + blinded_output_amount: bigint; revealed_output_amount: Amount; output_memo?: Memo | null; }; diff --git a/bindings/src/types/wallet-daemon-client/TransferOutput.ts b/bindings/src/types/wallet-daemon-client/TransferOutput.ts index dd87db0d4c..bf74034cc4 100644 --- a/bindings/src/types/wallet-daemon-client/TransferOutput.ts +++ b/bindings/src/types/wallet-daemon-client/TransferOutput.ts @@ -16,7 +16,7 @@ export type TransferOutput = { /** * Amount to spend to a blinded output */ - blinded_amount: Amount; + blinded_amount: bigint; /** * Optional memo to include a memo in the output. This memo is encrypted and can only be read by the recipient. */ diff --git a/clients/javascript/wallet_daemon_client/src/index.ts b/clients/javascript/wallet_daemon_client/src/index.ts index c31506d4ff..d4fe955fb1 100644 --- a/clients/javascript/wallet_daemon_client/src/index.ts +++ b/clients/javascript/wallet_daemon_client/src/index.ts @@ -17,7 +17,7 @@ import type { AccountSetDefaultRequest, AccountSetDefaultResponse, AccountsGetBalancesRequest, - AccountsGetBalancesResponse, AccountsGetPayRefAddressRequest, AccountsGetPayRefAddressResponse, + AccountsGetBalancesResponse, AccountsListRequest, AccountsListResponse, AccountsRenameRequest, @@ -177,10 +177,6 @@ export class WalletDaemonClient { return this.__invokeRpc("accounts.create", params); } - public accountsGetPayRefAddress(params: AccountsGetPayRefAddressRequest): Promise { - return this.__invokeRpc("accounts.get_pay_ref_address", params); - } - public accountsRename(params: AccountsRenameRequest): Promise { return this.__invokeRpc("accounts.rename", params); } diff --git a/clients/wallet_daemon_client/src/types.rs b/clients/wallet_daemon_client/src/types.rs index ec2d8a23ba..f278cba6da 100644 --- a/clients/wallet_daemon_client/src/types.rs +++ b/clients/wallet_daemon_client/src/types.rs @@ -490,7 +490,7 @@ pub struct AccountsTransferResponse { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct ProofsGenerateRequest { - pub amount: Amount, + pub confidential_amount: u64, pub reveal_amount: Amount, #[serde(deserialize_with = "opt_string_or_struct")] pub account: Option, @@ -529,7 +529,7 @@ pub struct ProofsCancelRequest { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct ConfidentialCreateOutputProofRequest { - pub amount: Amount, + pub amount: u64, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -1075,7 +1075,10 @@ pub struct TransferStatementRequest { impl TransferStatementRequest { pub fn total_output_amount(&self) -> Amount { - self.outputs.iter().map(|o| o.blinded_amount + o.revealed_amount).sum() + self.outputs + .iter() + .map(|o| Amount::from(o.blinded_amount) + o.revealed_amount) + .sum() } } @@ -1129,7 +1132,7 @@ pub struct StealthTransferRequest { #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct StealthTransfer { pub destination_address: OotleAddress, - pub blinded_output_amount: Amount, + pub blinded_output_amount: u64, pub revealed_output_amount: Amount, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_memo: Option, diff --git a/crates/engine/tests/burn.rs b/crates/engine/tests/burn.rs index 14e417ff96..ebd0328414 100644 --- a/crates/engine/tests/burn.rs +++ b/crates/engine/tests/burn.rs @@ -10,7 +10,7 @@ fn it_burns_all_resource_types() { let mut test = TemplateTest::new(["tests/templates/burn"]); let recall_template = test.get_template_address("Burn"); - let (mut initial_supply, _mask, _) = generate_confidential_output_statement(Amount::from(1000), None); + let (mut initial_supply, _mask, _) = generate_confidential_output_statement(1000, None); initial_supply.output_revealed_amount = Amount::from(1000); let result = test.execute_expect_success( diff --git a/crates/engine/tests/confidential.rs b/crates/engine/tests/confidential.rs index cfc8697e50..2ed8afbc45 100644 --- a/crates/engine/tests/confidential.rs +++ b/crates/engine/tests/confidential.rs @@ -68,7 +68,7 @@ fn setup( #[test] fn mint_initial_commitment() { - let (confidential_proof, _mask, _change) = generate_confidential_output_statement(Amount::from(100), None); + let (confidential_proof, _mask, _change) = generate_confidential_output_statement(100, None); let (test, _faucet, faucet_resx) = setup(confidential_proof, None); let resource = test @@ -359,10 +359,7 @@ fn multi_commitment_join() { let withdraw_proof1 = generate_withdraw_proof(&faucet_mask, 1000, Some(99_000), 0); let withdraw_proof2 = generate_withdraw_proof(withdraw_proof1.change_mask.as_ref().unwrap(), 1000, Some(98_000), 0); let join_proof = generate_withdraw_proof_with_inputs( - &[ - (withdraw_proof1.output_mask, 1000.into()), - (withdraw_proof2.output_mask, 1000.into()), - ], + &[(withdraw_proof1.output_mask, 1000), (withdraw_proof2.output_mask, 1000)], 0, 2000, None, diff --git a/crates/engine/tests/recall.rs b/crates/engine/tests/recall.rs index c99253c96c..6574e0214c 100644 --- a/crates/engine/tests/recall.rs +++ b/crates/engine/tests/recall.rs @@ -21,7 +21,7 @@ fn it_recalls_all_resource_types() { let recall_template = test.get_template_address("Recall"); let (account, _, _) = test.create_empty_account(); - let (mut initial_supply, mask, _) = generate_confidential_output_statement(Amount::from(1000), None); + let (mut initial_supply, mask, _) = generate_confidential_output_statement(1000, None); initial_supply.output_revealed_amount = Amount::from(1000); let result = test.execute_expect_success( diff --git a/crates/engine/tests/stealth.rs b/crates/engine/tests/stealth.rs index 6acdb6da0c..a681189ee5 100644 --- a/crates/engine/tests/stealth.rs +++ b/crates/engine/tests/stealth.rs @@ -100,7 +100,7 @@ fn basic_transfer() { let transfer = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }], 0, Some(100), @@ -138,7 +138,7 @@ fn programmatic_transfer() { let transfer = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }], 0, Some(75), @@ -174,7 +174,7 @@ fn transfer_with_revealed_outputs() { let transfer = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[1].clone(), - value: 1000.into(), + value: 1000, }], 0, [100, 200], @@ -216,11 +216,11 @@ fn transfer_revealed_between_accounts() { &[ MaskAndValue { mask: mint.output_masks[2].clone(), - value: 10000.into(), + value: 10000, }, MaskAndValue { mask: mint.output_masks[1].clone(), - value: 1000.into(), + value: 1000, }, ], 0, @@ -270,7 +270,7 @@ fn transfer_invalid_balance_in_statement() { let transfer_from_faucet = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }], 0, [99], @@ -302,7 +302,7 @@ fn transfer_invalid_ownership_proof() { let mut transfer_from_faucet = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }], 0, [99], @@ -340,7 +340,7 @@ fn transfer_invalid_range_proof_in_statement() { let mut transfer_from_faucet = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }], 0, [99], @@ -389,11 +389,11 @@ fn many_outputs_in_one_transfer() { let transfer_from_faucet = stealth::generate_transfer_data( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 1000.into(), + value: 1000, }], 0, iter::repeat_n( - 1000 / limits::STEALTH_LIMITS.max_outputs, + u64::try_from(1000 / limits::STEALTH_LIMITS.max_outputs).unwrap(), limits::STEALTH_LIMITS.max_outputs, ), 0, @@ -458,7 +458,7 @@ fn mint_with_view_key() { let withdraw_proof = stealth::generate_transfer_data_with_view_key( &[MaskAndValue { mask: mint.output_masks[0].clone(), - value: 1000.into(), + value: 1000, }], 0, [100, 200, 200, 200, 200, 100], @@ -502,11 +502,11 @@ fn freeze_then_attempt_spend() { &[ MaskAndValue { mask: mint.output_masks[0].clone(), - value: 100.into(), + value: 100, }, MaskAndValue { mask: mint.output_masks[1].clone(), - value: 1000.into(), + value: 1000, }, ], 0, @@ -577,11 +577,11 @@ fn burn_then_attempt_spend() { &[ MaskAndValue { mask: mint.output_masks[0].clone(), - value: outputs[0].into(), + value: outputs[0], }, MaskAndValue { mask: mint.output_masks[1].clone(), - value: outputs[1].into(), + value: outputs[1], }, ], 0, @@ -643,11 +643,11 @@ fn transfer_fails_if_tx_signed_by_wrong_signer() { &[ MaskAndValue { mask: mint.output_masks[2].clone(), - value: 10000.into(), + value: 10000, }, MaskAndValue { mask: mint.output_masks[1].clone(), - value: 1000.into(), + value: 1000, }, ], 0, diff --git a/crates/engine_types/src/crypto/helpers.rs b/crates/engine_types/src/crypto/helpers.rs index 1d5266a6c4..5ce3e576f3 100644 --- a/crates/engine_types/src/crypto/helpers.rs +++ b/crates/engine_types/src/crypto/helpers.rs @@ -70,6 +70,11 @@ pub fn commit_amount_checked(mask: &RistrettoSecretKey, amount: Amount) -> Optio Some(get_commitment_factory().commit(mask, &v)) } +/// Creates a Pedersen commitment to the given u64 amount using the provided mask. +pub fn commit_u64_amount(mask: &RistrettoSecretKey, amount: u64) -> PedersenCommitment { + get_commitment_factory().commit_value(mask, amount) +} + /// Converts a `Amount` to a `RistrettoSecretKey`. /// /// # Returns diff --git a/crates/template_lib_types/src/amount/amount.rs b/crates/template_lib_types/src/amount/amount.rs index bb54cc2f28..6d029a1899 100644 --- a/crates/template_lib_types/src/amount/amount.rs +++ b/crates/template_lib_types/src/amount/amount.rs @@ -117,26 +117,28 @@ impl Amount { } } - pub fn sum_from_positive>(iter: I) -> Option { + pub fn sum_from_positive, I: Iterator>(iter: I) -> Option { let mut sum = Self::zero(); for amount in iter { - sum = sum.checked_add_positive(amount)?; + sum = sum.checked_add_positive(amount.into())?; } Some(sum) } - /// Returns the difference of two amounts, saturating at `0` if the result is negative. + /// Returns the difference of two amounts, saturating at `Amount::MIN` if the result underflows. + /// If negative results are not desired, use `saturating_sub_positive`. pub const fn saturating_sub(&self, other: Self) -> Self { Self::new(self.into_inner_value().saturating_sub(other.into_inner_value())) } - /// Returns the difference of two amounts, returning `None` if the result is negative. - pub fn saturating_sub_positive(&self, other: Self) -> Option { + /// Returns the difference of two amounts, returning 0 if the result would be negative. + /// Input numbers may be negative. + pub fn saturating_sub_positive(&self, other: Self) -> Self { if *self < other { - return None; + return Self::zero(); } - Some(Self::new(self.inner_value() - other.inner_value())) + Self::new(self.inner_value().saturating_sub(other.into_inner_value())) } /// Returns the difference of two amounts, returning `None` if the result is negative or if either amount is @@ -145,7 +147,11 @@ impl Amount { if self.is_negative() || other.is_negative() { return None; } - self.saturating_sub_positive(other) + if *self < other { + return None; + } + + self.checked_sub(other) } /// Returns the product of two amounts, returning `None` if the result overflows. @@ -157,7 +163,7 @@ impl Amount { } /// Returns the product of two amounts, saturating at `i64::MAX` if the result exceeds it. - pub const fn saturating_mul(&self, other: &Self) -> Self { + pub const fn saturating_mul(&self, other: Self) -> Self { Self::new(self.into_inner_value().saturating_mul(other.into_inner_value())) } @@ -509,6 +515,8 @@ mod tests { assert_eq!(c, Amount::from(10)); let d = a.checked_sub(b).unwrap(); assert_eq!(d, Amount::from(-2)); + let d = a.checked_sub_positive(b); + assert!(d.is_none()); let e = a.checked_mul(b).unwrap(); assert_eq!(e, Amount::from(24)); let f = b.checked_div(a).unwrap(); @@ -536,6 +544,43 @@ mod tests { assert!(overflow_pow.is_none(), "Overflow should return None"); } + #[test] + fn saturating_arithmetic() { + let a = Amount::from(4); + let b = Amount::from(6); + let c = a.saturating_add(Amount::MAX); + assert_eq!(c, Amount::MAX); + let d = a.saturating_sub_positive(b); + assert_eq!(d, Amount::ZERO); + let d = a.saturating_sub_positive(Amount::from(-100)); + assert_eq!(d, 104); + let e = a.saturating_mul(Amount::MAX); + assert_eq!(e, Amount::MAX); + let f = b.saturating_div(&a); + assert_eq!(f, Amount::from(1)); + + // Test saturating overflow + let max = Amount::MAX; + let overflow_add = max.saturating_add(Amount::from(1)); + assert_eq!( + overflow_add, + Amount::MAX, + "Saturating add should return MAX on overflow" + ); + let overflow_sub = Amount::MIN.saturating_sub(Amount::from(1)); + assert_eq!( + overflow_sub, + Amount::MIN, + "Saturating sub should return MIN on underflow" + ); + let overflow_mul = max.saturating_mul(Amount::from(2)); + assert_eq!( + overflow_mul, + Amount::MAX, + "Saturating mul should return MAX on overflow" + ); + } + #[test] #[cfg(feature = "extra-arith")] fn extra_arithmetic() { diff --git a/crates/template_test_tooling/src/support/confidential.rs b/crates/template_test_tooling/src/support/confidential.rs index f2b9d7d018..6dbaf8c32e 100644 --- a/crates/template_test_tooling/src/support/confidential.rs +++ b/crates/template_test_tooling/src/support/confidential.rs @@ -14,24 +14,24 @@ use tari_template_lib::{ types::{Amount, EncryptedData}, }; -pub fn generate_confidential_output_statement>( - output_amount: A, - change: Option, +pub fn generate_confidential_output_statement( + output_amount: u64, + change: Option, ) -> (ConfidentialOutputStatement, PrivateKey, Option) { - generate_confidential_proof_internal(output_amount.into(), change.map(Into::into), None) + generate_confidential_proof_internal(output_amount, change, None) } -pub fn generate_confidential_proof_with_view_key>( - output_amount: A, - change: Option, +pub fn generate_confidential_proof_with_view_key( + output_amount: u64, + change: Option, view_key: &RistrettoPublicKey, ) -> (ConfidentialOutputStatement, PrivateKey, Option) { - generate_confidential_proof_internal(output_amount.into(), change.map(Into::into), Some(view_key.clone())) + generate_confidential_proof_internal(output_amount, change, Some(view_key.clone())) } fn generate_confidential_proof_internal( - output_amount: Amount, - change: Option, + output_amount: u64, + change: Option, view_key: Option, ) -> (ConfidentialOutputStatement, PrivateKey, Option) { let mask = PrivateKey::random(&mut OsRng); @@ -78,15 +78,18 @@ impl ConfidentialWithdrawProofOutput { pub fn generate_withdraw_proof>( input_mask: &PrivateKey, - output_amount: A, - change_amount: Option, + output_amount: u64, + change_amount: Option, revealed_amount: A, ) -> ConfidentialWithdrawProofOutput { - let output_amount = output_amount.into(); - let change_amount = change_amount.map(|a| a.into()); let revealed_amount = revealed_amount.into(); - let total_amount = output_amount + change_amount.unwrap_or_else(Amount::zero) + revealed_amount; + let total_amount = output_amount + + change_amount.unwrap_or(0) + + revealed_amount.to_u64_checked().expect( + "Revealed amount is too large to fit in u64 when generating withdraw proof. This is due to a current \ + limitation of the test tooling.", + ); generate_withdraw_proof_internal( &[(input_mask.clone(), total_amount)], @@ -99,17 +102,17 @@ pub fn generate_withdraw_proof>( } pub fn generate_withdraw_proof_with_inputs>( - inputs: &[(PrivateKey, Amount)], + inputs: &[(PrivateKey, u64)], input_revealed_amount: A, - output_amount: A, - change_amount: Option, + output_amount: u64, + change_amount: Option, revealed_output_amount: A, ) -> ConfidentialWithdrawProofOutput { generate_withdraw_proof_internal( inputs, input_revealed_amount.into(), - output_amount.into(), - change_amount.map(Into::into), + output_amount, + change_amount, revealed_output_amount.into(), None, ) @@ -117,32 +120,32 @@ pub fn generate_withdraw_proof_with_inputs>( pub fn generate_withdraw_proof_with_view_key>( input_mask: &PrivateKey, - input_value: A, - output_amount: A, - change_amount: Option, + input_value: u64, + output_amount: u64, + change_amount: Option, revealed_amount: A, view_key: &RistrettoPublicKey, ) -> ConfidentialWithdrawProofOutput { generate_withdraw_proof_internal( - &[(input_mask.clone(), input_value.into())], + &[(input_mask.clone(), input_value)], Amount::zero(), - output_amount.into(), - change_amount.map(Into::into), + output_amount, + change_amount, revealed_amount.into(), Some(view_key.clone()), ) } fn generate_withdraw_proof_internal( - inputs: &[(PrivateKey, Amount)], + inputs: &[(PrivateKey, u64)], input_revealed_amount: Amount, - output_amount: Amount, - change_amount: Option, + output_amount: u64, + change_amount: Option, revealed_output_amount: Amount, view_key: Option, ) -> ConfidentialWithdrawProofOutput { // If the amount is zero, we omit the output UTXO, therefore the mask is zero - let output_mask = if output_amount.is_zero() { + let output_mask = if output_amount == 0 { Default::default() } else { PrivateKey::random(&mut OsRng) diff --git a/crates/template_test_tooling/src/support/stealth.rs b/crates/template_test_tooling/src/support/stealth.rs index 28c6e6e679..94420fab53 100644 --- a/crates/template_test_tooling/src/support/stealth.rs +++ b/crates/template_test_tooling/src/support/stealth.rs @@ -27,25 +27,26 @@ use tari_template_lib::{ }, }; -pub fn generate_stealth_output_statement, A: Into>( +pub fn generate_stealth_output_statement, A: Into>( output_amounts: I, revealed_output_amount: A, ) -> (StealthOutputsStatement, Vec) { generate_stealth_statement_internal( - &output_amounts.into_iter().map(Into::into).collect::>(), + &output_amounts.into_iter().collect::>(), revealed_output_amount.into(), None, ) } -pub fn generate_mint_statement, A: Into + Copy>( +pub fn generate_mint_statement, A: Into + Copy>( stealth_output_amounts: I, revealed_output_amount: A, view_key: Option<&RistrettoPublicKey>, required_signer: RistrettoPublicKeyBytes, ) -> StealthUnblindedTransferData { - let stealth_output_amounts = stealth_output_amounts.into_iter().map(Into::into).collect::>(); - let total_revealed_inputs = stealth_output_amounts.iter().copied().sum::() + revealed_output_amount.into(); + let stealth_output_amounts = stealth_output_amounts.into_iter().collect::>(); + let total_revealed_inputs = + stealth_output_amounts.iter().copied().map(Amount::from).sum::() + revealed_output_amount.into(); match view_key { Some(view_key) => generate_transfer_data_with_view_key( &[], @@ -66,20 +67,20 @@ pub fn generate_mint_statement, A: Into + Copy } } -pub fn generate_stealth_statement_with_view_key, A: Into>( +pub fn generate_stealth_statement_with_view_key>( output_amounts: I, revealed_output_amount: Amount, view_key: &RistrettoPublicKey, ) -> (StealthOutputsStatement, Vec) { generate_stealth_statement_internal( - &output_amounts.into_iter().map(Into::into).collect::>(), + &output_amounts.into_iter().collect::>(), revealed_output_amount, Some(view_key.clone()), ) } fn generate_stealth_statement_internal( - output_amounts: &[Amount], + output_amounts: &[u64], revealed_output_amount: Amount, view_key: Option, ) -> (StealthOutputsStatement, Vec) { @@ -121,7 +122,7 @@ pub fn generate_transfer_data( required_signer: RistrettoPublicKeyBytes, ) -> StealthUnblindedTransferData where - O: IntoIterator, + O: IntoIterator, A: Into, { generate_transfer_data_internal( @@ -134,7 +135,7 @@ where ) } -pub fn generate_transfer_data_with_view_key, A: Into>( +pub fn generate_transfer_data_with_view_key, A: Into>( inputs: &[MaskAndValue], revealed_input_amount: A, output_amounts: I, @@ -163,7 +164,7 @@ pub fn test_sender_public_nonce() -> RistrettoPublicKey { test_sender_nonce_keypair().1 } -fn generate_transfer_data_internal, A: Into>( +fn generate_transfer_data_internal, A: Into>( inputs: &[MaskAndValue], revealed_input_amount: A, output_amounts: I, @@ -173,14 +174,9 @@ fn generate_transfer_data_internal, A: Into>( ) -> StealthUnblindedTransferData { let outputs = output_amounts .into_iter() - .map(|a| { - // If the amount is zero, we omit the output UTXO, therefore, the mask is zero - let amount = a.into(); - let output_mask = if amount.is_zero() { - Default::default() - } else { - RistrettoSecretKey::random(&mut OsRng) - }; + .filter(|&a| a > 0) + .map(|amount| { + let output_mask = RistrettoSecretKey::random(&mut OsRng); // For testing purposes, we use the mask as the owner key let output_owner_public_key = RistrettoPublicKey::from_secret_key(&output_mask); let statement = UnblindedOutputWitness { diff --git a/crates/transaction/Cargo.toml b/crates/transaction/Cargo.toml index 51e784cb66..a835d772a0 100644 --- a/crates/transaction/Cargo.toml +++ b/crates/transaction/Cargo.toml @@ -24,8 +24,5 @@ ts-rs = { workspace = true, optional = true } hex = { workspace = true } thiserror = { workspace = true } -[dev-dependencies] -tari_bor = { workspace = true } - [features] -ts = ["ts-rs"] +ts = ["ts-rs", "tari_engine_types/ts", "tari_ootle_common_types/ts", "tari_template_lib/ts", "tari_bor/ts"] diff --git a/crates/wallet/crypto/src/bullet_proof.rs b/crates/wallet/crypto/src/bullet_proof.rs index 08a3c01e0d..8abff27630 100644 --- a/crates/wallet/crypto/src/bullet_proof.rs +++ b/crates/wallet/crypto/src/bullet_proof.rs @@ -27,10 +27,7 @@ pub fn generate_extended_bullet_proof<'a, I: IntoIterator((commitments, agg_input + &input.mask)) }, @@ -80,7 +76,7 @@ pub fn create_output_statement( let proof_change_statement = change_statement .as_ref() .map(|stmt| -> Result<_, ConfidentialProofError> { - let change_commitment = stmt.to_commitment().ok_or(ConfidentialProofError::NegativeAmount)?; + let change_commitment = stmt.to_commitment(); Ok(UnspentOutput { commitment: change_commitment.to_byte_type(), sender_public_nonce: RistrettoPublicKeyBytes::from_bytes(stmt.sender_public_nonce.as_bytes()) @@ -97,17 +93,12 @@ pub fn create_output_statement( }) }) .transpose()?; - let confidential_output_value = output_statement - .as_ref() - .map(|o| o.amount) - .unwrap_or_default() - .non_negative_checked() - .ok_or(ConfidentialProofError::NegativeAmount)?; + let confidential_output_value = output_statement.as_ref().map(|o| o.amount).unwrap_or_default(); let proof_output_statement = output_statement .as_ref() .map(|stmt| { - let commitment = stmt.to_commitment().ok_or(ConfidentialProofError::NegativeAmount)?; + let commitment = stmt.to_commitment(); Ok::<_, ConfidentialProofError>(UnspentOutput { commitment: commitment.to_byte_type(), sender_public_nonce: stmt.sender_public_nonce.to_byte_type(), @@ -140,11 +131,11 @@ mod tests { use rand::rngs::OsRng; use tari_crypto::{keys::SecretKey, ristretto::RistrettoSecretKey}; use tari_engine_types::confidential::validate_confidential_statement; - use tari_template_lib::types::{Amount, EncryptedData}; + use tari_template_lib::types::EncryptedData; use super::*; - fn create_valid_proof(amount: Amount, minimum_value_promise: u64) -> ConfidentialOutputStatement { + fn create_valid_proof(amount: u64, minimum_value_promise: u64) -> ConfidentialOutputStatement { let mask = RistrettoSecretKey::random(&mut OsRng); create_output_statement( Some(&UnblindedOutputWitness { @@ -164,13 +155,13 @@ mod tests { #[test] fn it_is_valid_if_proof_is_valid() { - let proof = create_valid_proof(100.into(), 0); + let proof = create_valid_proof(100, 0); validate_confidential_statement(&proof, None).unwrap(); } #[test] fn it_is_invalid_if_minimum_value_changed() { - let mut proof = create_valid_proof(100.into(), 100); + let mut proof = create_valid_proof(100, 100); proof.output.as_mut().unwrap().minimum_value_promise = 99; validate_confidential_statement(&proof, None).unwrap_err(); proof.output.as_mut().unwrap().minimum_value_promise = 1000; diff --git a/crates/wallet/crypto/src/encrypted_data.rs b/crates/wallet/crypto/src/encrypted_data.rs index 58524dedca..bb97254004 100644 --- a/crates/wallet/crypto/src/encrypted_data.rs +++ b/crates/wallet/crypto/src/encrypted_data.rs @@ -37,13 +37,7 @@ pub fn unblind_output( let encryption_key = kdfs::encrypted_data_dh_kdf_aead(claim_secret, reciprocal_public_key); let decrypted = decrypt_data(&encryption_key, output_commitment, output_encrypted_value, skip_memo)?; - let commitment = decrypted.to_commitment().ok_or_else(|| WalletCryptoError::Invariant { - // Currently impossible - details: format!( - "Failed to create commitment from decrypted data (value {} exceeds u64::MAX)", - decrypted.mask_and_value.value - ), - })?; + let commitment = decrypted.to_commitment(); if output_commitment.as_bytes() == commitment.as_bytes() { Ok(decrypted) } else { @@ -70,10 +64,7 @@ pub fn decrypt_data( ) -> Result { let (value, mask, memo) = decrypt_inner(encryption_key, commitment, encrypted_data, skip_memo)?; Ok(DecryptedData { - mask_and_value: MaskAndValue { - value: value.into(), - mask, - }, + mask_and_value: MaskAndValue { value, mask }, memo, }) } diff --git a/crates/wallet/crypto/src/stealth.rs b/crates/wallet/crypto/src/stealth.rs index e4ef3ef5b2..7f55871758 100644 --- a/crates/wallet/crypto/src/stealth.rs +++ b/crates/wallet/crypto/src/stealth.rs @@ -59,14 +59,7 @@ where let (inputs_to_spend, agg_input_mask) = inputs.into_iter().try_fold( (Vec::with_capacity(num_inputs), RistrettoSecretKey::default()), |(mut inputs, agg_input), input| { - let commitment = - input - .mask_and_value - .to_commitment() - .ok_or_else(|| WalletCryptoError::InvalidArgument { - name: "input value", - details: format!("Input value {} must be non-negative", input.mask_and_value.value), - })?; + let commitment = input.mask_and_value.to_commitment(); let signature = generate_stealth_owner_proof_signature( &input.owner_secret, @@ -125,10 +118,7 @@ pub fn create_outputs_statement<'a, Outputs: IntoIterator StealthOutputsStatement { + fn create_valid_proof(amount: u64, minimum_value_promise: u64) -> StealthOutputsStatement { let mask = RistrettoSecretKey::random(&mut OsRng); create_outputs_statement( &[UnblindedStealthOutputWitness { @@ -196,13 +186,13 @@ mod tests { #[test] fn it_is_valid_if_proof_is_valid() { - let proof = create_valid_proof(100.into(), 0); + let proof = create_valid_proof(100, 0); validate_stealth_outputs_statement(&proof, None).unwrap(); } #[test] fn it_is_invalid_if_minimum_value_changed() { - let mut proof = create_valid_proof(100.into(), 100); + let mut proof = create_valid_proof(100, 100); proof.outputs[0].output.minimum_value_promise = 99; validate_stealth_outputs_statement(&proof, None).unwrap_err(); proof.outputs[0].output.minimum_value_promise = 1000; diff --git a/crates/wallet/crypto/src/unblinded_statement.rs b/crates/wallet/crypto/src/unblinded_statement.rs index fe6b5f311f..b5a75aeaf3 100644 --- a/crates/wallet/crypto/src/unblinded_statement.rs +++ b/crates/wallet/crypto/src/unblinded_statement.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: BSD-3-Clause use tari_crypto::ristretto::{pedersen::PedersenCommitment, RistrettoPublicKey, RistrettoSecretKey}; -use tari_engine_types::crypto::commit_amount_checked; -use tari_template_lib::types::{crypto::UtxoTag, Amount, EncryptedData}; +use tari_engine_types::crypto::commit_u64_amount; +use tari_template_lib::types::{crypto::UtxoTag, EncryptedData}; use crate::memo::Memo; #[derive(Debug, Clone)] pub struct UnblindedOutputWitness { - pub amount: Amount, + pub amount: u64, pub mask: RistrettoSecretKey, pub sender_public_nonce: RistrettoPublicKey, pub minimum_value_promise: u64, @@ -18,8 +18,8 @@ pub struct UnblindedOutputWitness { } impl UnblindedOutputWitness { - pub fn to_commitment(&self) -> Option { - commit_amount_checked(&self.mask, self.amount) + pub fn to_commitment(&self) -> PedersenCommitment { + commit_u64_amount(&self.mask, self.amount) } } @@ -32,17 +32,17 @@ pub struct UnblindedStealthOutputWitness { #[derive(Debug, Clone)] pub struct MaskAndValue { - pub value: Amount, + pub value: u64, pub mask: RistrettoSecretKey, } impl MaskAndValue { - pub fn new(value: Amount, mask: RistrettoSecretKey) -> Self { + pub fn new(value: u64, mask: RistrettoSecretKey) -> Self { Self { value, mask } } - pub fn to_commitment(&self) -> Option { - commit_amount_checked(&self.mask, self.value) + pub fn to_commitment(&self) -> PedersenCommitment { + commit_u64_amount(&self.mask, self.value) } } @@ -57,7 +57,7 @@ impl DecryptedData { self.mask_and_value } - pub fn value(&self) -> Amount { + pub fn value(&self) -> u64 { self.mask_and_value.value } @@ -69,7 +69,7 @@ impl DecryptedData { self.memo.as_ref() } - pub fn to_commitment(&self) -> Option { + pub fn to_commitment(&self) -> PedersenCommitment { self.mask_and_value.to_commitment() } } diff --git a/crates/wallet/crypto/src/viewable_balance_proof.rs b/crates/wallet/crypto/src/viewable_balance_proof.rs index b9390e2164..587b92a0c9 100644 --- a/crates/wallet/crypto/src/viewable_balance_proof.rs +++ b/crates/wallet/crypto/src/viewable_balance_proof.rs @@ -8,12 +8,12 @@ use tari_crypto::{ ristretto::{pedersen::PedersenCommitment, RistrettoPublicKey, RistrettoSchnorr, RistrettoSecretKey}, }; use tari_engine_types::{ - crypto::{convert_amount_to_secret, get_commitment_factory, messages}, + crypto::{get_commitment_factory, messages}, ToByteType, }; use tari_template_lib::{ models::{ViewableBalanceProof, ViewableBalanceProofChallengeFields}, - prelude::{Amount, Scalar32Bytes}, + prelude::Scalar32Bytes, }; use tari_utilities::ByteArray; @@ -21,14 +21,13 @@ use crate::ConfidentialProofError; pub fn create_viewable_balance_proof( mask: &RistrettoSecretKey, - output_amount: Amount, + output_amount: u64, commitment: &PedersenCommitment, view_key: &RistrettoPublicKey, ) -> Result { let (elgamal_secret_nonce, elgamal_public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); let r = &elgamal_secret_nonce; - let output_amount_as_secret = - convert_amount_to_secret(&output_amount).ok_or(ConfidentialProofError::NegativeAmount)?; + let output_amount_as_secret = RistrettoSecretKey::from(output_amount); // E = v.G + rP let elgamal_encrypted = RistrettoPublicKey::from_secret_key(&output_amount_as_secret) + r * view_key; diff --git a/crates/wallet/crypto/tests/stealth_transfer_statement.rs b/crates/wallet/crypto/tests/stealth_transfer_statement.rs index 4e81e087b5..0078cf2370 100644 --- a/crates/wallet/crypto/tests/stealth_transfer_statement.rs +++ b/crates/wallet/crypto/tests/stealth_transfer_statement.rs @@ -196,7 +196,7 @@ mod stealth_tests { .map(|&(seed, amount)| { let (mask, public_key) = create_key_pair_from_seed(seed); UnblindedStealthInputWitness { - mask_and_value: MaskAndValue::new(Amount::from(amount), mask.clone()), + mask_and_value: MaskAndValue::new(amount, mask.clone()), owner_secret: mask, public_nonce: public_key, } @@ -204,17 +204,12 @@ mod stealth_tests { .collect() } - fn make_output_statements + Copy>(amounts: &[A]) -> Vec { + fn make_output_statements(amounts: &[u64]) -> Vec { amounts .iter() + .filter(|amount| **amount > 0) .map(|&amount| { - let amount = amount.into(); - // If the amount is zero, we omit the output UTXO, therefore, the mask is zero - let output_mask = if amount.is_zero() { - Default::default() - } else { - RistrettoSecretKey::random(&mut OsRng) - }; + let output_mask = RistrettoSecretKey::random(&mut OsRng); // For testing purposes, we use the mask as the owner key let output_owner_public_key = RistrettoPublicKey::from_secret_key(&output_mask); let statement = UnblindedOutputWitness { diff --git a/crates/wallet/crypto/tests/viewable_balance_proof.rs b/crates/wallet/crypto/tests/viewable_balance_proof.rs index b818606f20..dfe8bcaecd 100644 --- a/crates/wallet/crypto/tests/viewable_balance_proof.rs +++ b/crates/wallet/crypto/tests/viewable_balance_proof.rs @@ -16,7 +16,7 @@ use tari_template_lib::{ }; use tari_utilities::ByteArray; -fn create_output_statement(value: Amount, view_key: &RistrettoPublicKey) -> UnblindedOutputWitness { +fn create_output_statement(value: u64, view_key: &RistrettoPublicKey) -> UnblindedOutputWitness { let mask = RistrettoSecretKey::random(&mut OsRng); UnblindedOutputWitness { amount: value, @@ -44,7 +44,7 @@ fn it_allows_no_balance_proof_for_no_view_key() { #[test] fn it_errors_no_balance_proof_with_view_key() { let (_, view_key) = keypair_from_seed(1); - let output_statement = create_output_statement(123.into(), &view_key); + let output_statement = create_output_statement(123, &view_key); let proof = confidential::create_output_statement(Some(&output_statement), Amount::zero(), None, Amount::zero()).unwrap(); @@ -63,7 +63,7 @@ fn it_errors_with_balance_proof_and_no_view_key() { #[test] fn it_generates_a_valid_proof() { let (view_key_secret, view_key) = keypair_from_seed(1); - let output_statement = create_output_statement(123.into(), &view_key); + let output_statement = create_output_statement(123, &view_key); let timer = Instant::now(); let proof = @@ -94,8 +94,8 @@ fn it_generates_a_valid_proof() { #[test] fn serialize_deserialize() { let (_view_key_secret, view_key) = keypair_from_seed(1); - let output_statement = create_output_statement(123.into(), &view_key); - let change_statement = create_output_statement(123.into(), &view_key); + let output_statement = create_output_statement(123, &view_key); + let change_statement = create_output_statement(123, &view_key); let proof = confidential::create_withdraw_proof( &[], diff --git a/crates/wallet/sdk/Cargo.toml b/crates/wallet/sdk/Cargo.toml index c4a58eec76..d955ac7104 100644 --- a/crates/wallet/sdk/Cargo.toml +++ b/crates/wallet/sdk/Cargo.toml @@ -42,4 +42,14 @@ tari_ootle_wallet_storage_sqlite = { workspace = true } tempfile = { workspace = true } [features] -ts = ["ts-rs", "tari_ootle_address/ts"] +ts = [ + "ts-rs", + "tari_ootle_address/ts", + "tari_bor/ts", + "tari_engine_types/ts", + "tari_template_abi/ts", + "tari_template_lib/ts", + "tari_transaction/ts", + "tari_ootle_wallet_crypto/ts", + "tari_consensus_types/ts" +] diff --git a/crates/wallet/sdk/src/apis/confidential_crypto.rs b/crates/wallet/sdk/src/apis/confidential_crypto.rs index f959094b2d..cfdb479176 100644 --- a/crates/wallet/sdk/src/apis/confidential_crypto.rs +++ b/crates/wallet/sdk/src/apis/confidential_crypto.rs @@ -94,7 +94,7 @@ impl ConfidentialCryptoApi { revealed_amount: A, ) -> Result { let proof = confidential::create_output_statement( - Some(statement).filter(|s| !s.amount.is_zero()), + Some(statement).filter(|s| s.amount > 0), revealed_amount.into(), None, Amount::zero(), diff --git a/crates/wallet/sdk/src/apis/confidential_outputs.rs b/crates/wallet/sdk/src/apis/confidential_outputs.rs index 2be54abe64..7f2bc801d6 100644 --- a/crates/wallet/sdk/src/apis/confidential_outputs.rs +++ b/crates/wallet/sdk/src/apis/confidential_outputs.rs @@ -253,7 +253,7 @@ where TStore: WalletStore commitment, e ); - (Amount::zero(), None, OutputStatus::Invalid) + (0, None, OutputStatus::Invalid) }, }; @@ -261,7 +261,7 @@ where TStore: WalletStore account_address: account.component_address, vault_id, commitment, - value, + value: value.into(), sender_public_nonce: Some(output_stealth_public_nonce.to_byte_type()), view_only_key_id: key.key_id, owner_key_id: account.owner_key_id, diff --git a/crates/wallet/sdk/src/apis/confidential_transfer.rs b/crates/wallet/sdk/src/apis/confidential_transfer.rs index b84ecb42fe..ad753669f0 100644 --- a/crates/wallet/sdk/src/apis/confidential_transfer.rs +++ b/crates/wallet/sdk/src/apis/confidential_transfer.rs @@ -159,7 +159,10 @@ where .confidential_outputs_api .resolve_output_masks(confidential_inputs)?; - let total_confidential_spent = confidential_inputs.iter().map(|i| i.value).sum::(); + let total_confidential_spent = confidential_inputs + .iter() + .map(|i| Amount::from(i.value)) + .sum::(); self.locks_api .lock_funds_in_vault(lock_id, &src_vault.id, revealed_to_spend)?; @@ -185,9 +188,7 @@ where .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) - .unwrap_or_else(Amount::zero); + let revealed_to_spend = spend_amount.saturating_sub_positive(amount_locked); if src_vault.revealed_balance < revealed_to_spend { return Err(ConfidentialTransferApiError::InsufficientFunds); @@ -343,15 +344,12 @@ where let change_value = statement.amount; - if change_value.is_positive() { + if change_value > 0 { self.confidential_outputs_api.add_output(ConfidentialOutputModel { account_address: *account.component_address(), vault_id: src_vault.id, - commitment: statement - .to_commitment() - .expect("BUG: to_commitment negative amount") - .to_byte_type(), - value: change_value, + commitment: statement.to_commitment().to_byte_type(), + value: change_value.into(), sender_public_nonce: Some(statement.sender_public_nonce.to_byte_type()), view_only_key_id: account_key.key_id, owner_key_id: Some(account_key.key_id), @@ -441,9 +439,7 @@ where } let mask = self.key_manager_api.next_key(KeyBranch::ConfidentialMask)?; - - let (nonce, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); - let encrypted_data = self.crypto_api.encrypt_value_and_mask( + let amount = confidential_amount .to_u64_checked() .ok_or_else(|| ConfidentialTransferApiError::AmountOverflow { @@ -451,15 +447,15 @@ where details: "Confidential amount exceeds u64. This is currently a limitation due to the format of \ EncryptedData" .to_string(), - })?, - &mask.key, - dest_public_key, - &nonce, - memo, - )?; + })?; + + let (nonce, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); + let encrypted_data = + self.crypto_api + .encrypt_value_and_mask(amount, &mask.key, dest_public_key, &nonce, memo)?; Ok(UnblindedOutputWitness { - amount: confidential_amount, + amount, mask: mask.key, sender_public_nonce: public_nonce, encrypted_data, @@ -544,7 +540,7 @@ impl InputsToSpend { } pub fn total_confidential_amount(&self) -> Amount { - self.confidential.iter().map(|o| o.value).sum() + self.confidential.iter().map(|o| Amount::from(o.value)).sum() } } diff --git a/crates/wallet/sdk/src/apis/stealth_crypto.rs b/crates/wallet/sdk/src/apis/stealth_crypto.rs index ae80139840..e2700487a9 100644 --- a/crates/wallet/sdk/src/apis/stealth_crypto.rs +++ b/crates/wallet/sdk/src/apis/stealth_crypto.rs @@ -121,7 +121,7 @@ impl StealthCryptoApi { revealed_amount: A, ) -> Result { let proof = confidential::create_output_statement( - Some(statement).filter(|s| !s.amount.is_zero()), + Some(statement).filter(|s| s.amount > 0), revealed_amount.into(), None, Amount::zero(), diff --git a/crates/wallet/sdk/src/apis/stealth_outputs.rs b/crates/wallet/sdk/src/apis/stealth_outputs.rs index 3f2ffb6cde..2674c80f2e 100644 --- a/crates/wallet/sdk/src/apis/stealth_outputs.rs +++ b/crates/wallet/sdk/src/apis/stealth_outputs.rs @@ -1,6 +1,8 @@ // Copyright 2025 The Tari Project // SPDX-License-Identifier: BSD-3-Clause +use std::collections::HashMap; + use digest::crypto_common::rand_core::OsRng; use log::*; use tari_crypto::{ @@ -9,6 +11,7 @@ use tari_crypto::{ }; use tari_engine_types::{ component::derive_component_address_from_public_key, + limits, FromByteType, ToByteType, Utxo, @@ -42,12 +45,15 @@ use crate::{ stealth_transfer::{StealthOutputToCreate, UnblindedInputToSpend}, }, models::{ + input_selection, + input_selection::{branch_and_bound::KeyedInput, InputSelectionAlgorithm}, AccountAndViewKeys, InputSpendData, KeyBranch, KeyId, OutputStatus, StealthBalance, + StealthOutputInfo, StealthOutputModel, WalletLockId, }, @@ -86,7 +92,7 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { resource_address: &ResourceAddress, lock_id: WalletLockId, amount: A, - ) -> Result<(Vec, Amount), StealthOutputsApiError> { + ) -> Result<(Vec, Amount), StealthOutputsApiError> { let amount = amount .into() .non_negative_checked() @@ -94,14 +100,23 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { param: "amount", reason: "lock_outputs_for_at_least_amount: Amount must be non-negative".to_string(), })?; + if amount.is_zero() { + return Ok((Vec::new(), Amount::zero())); + } + self.store.with_write_tx(|tx| { - let (outputs, total_output_amount) = - self.lock_outputs_internal(tx, account_address, resource_address, amount, lock_id)?; + let (outputs, total_output_amount) = self.lock_outputs_internal( + tx, + account_address, + resource_address, + amount, + lock_id, + InputSelectionAlgorithm::BranchAndBound, + )?; if total_output_amount < amount { return Err(StealthOutputsApiError::InsufficientFunds); } - Ok((outputs, total_output_amount)) }) } @@ -114,49 +129,114 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { resource_address: &ResourceAddress, amount: Amount, locked_by_id: WalletLockId, - ) -> Result<(Vec, Amount), StealthOutputsApiError> { - self.store - .with_write_tx(|tx| self.lock_outputs_internal(tx, account_address, resource_address, amount, locked_by_id)) + ) -> Result<(Vec, Amount), StealthOutputsApiError> { + self.store.with_write_tx(|tx| { + self.lock_outputs_internal( + tx, + account_address, + resource_address, + amount, + locked_by_id, + InputSelectionAlgorithm::SmallestFirst, + ) + }) } - fn lock_outputs_internal( + fn lock_outputs_internal( &self, - tx: &mut TTx, + tx: &mut TStore::WriteTransaction<'_>, account_address: &ComponentAddress, resource_address: &ResourceAddress, amount: Amount, locked_by_id: WalletLockId, - ) -> Result<(Vec, Amount), StealthOutputsApiError> { + selection_algo: InputSelectionAlgorithm, + ) -> Result<(Vec, Amount), StealthOutputsApiError> { if amount.is_negative() { return Err(StealthOutputsApiError::InvalidParameter { param: "amount", reason: "lock_outputs_internal: Amount cannot be negative".to_string(), }); } - let mut total_output_amount = Amount::zero(); - let mut outputs = Vec::new(); - while total_output_amount < amount { - let output = tx - .stealth_outputs_lock_smallest_amount(account_address, resource_address, locked_by_id) - .optional()?; - match output { - Some(output) => { - total_output_amount += output.value; - outputs.push(output); - }, - None => { - debug!( - target: LOG_TARGET, - "No more outputs available to lock. Total locked amount: {}, required amount: {}", - total_output_amount, - amount - ); - break; - }, - } - } - Ok((outputs, total_output_amount)) + const INPUT_LIMIT: usize = limits::STEALTH_LIMITS.max_inputs; + + match selection_algo { + InputSelectionAlgorithm::SmallestFirst => { + let mut total_output_amount = Amount::zero(); + let mut outputs = Vec::new(); + while total_output_amount < amount { + let output = tx + .stealth_outputs_lock_smallest_amount(account_address, resource_address, locked_by_id) + .optional()?; + match output { + Some(output) => { + total_output_amount += Amount::from(output.value); + if outputs.len() >= INPUT_LIMIT { + warn!( + target: LOG_TARGET, + "Reached maximum input limit of {} when locking outputs.", + INPUT_LIMIT + ); + break; + } + + outputs.push(output); + }, + None => { + debug!( + target: LOG_TARGET, + "No more outputs available to lock. Total locked amount: {}, required amount: {}", + total_output_amount, + amount + ); + break; + }, + } + } + + let outputs = outputs.into_iter().map(|i| i.into_spend_data()).collect(); + Ok((outputs, total_output_amount)) + }, + InputSelectionAlgorithm::BranchAndBound => { + let unspent = + tx.stealth_outputs_get_unspent_for_spending(account_address, resource_address, locked_by_id)?; + + let mut unspent = unspent + .into_iter() + .map(|o| (o.commitment, InputSpendData::from(o))) + .collect::>(); + + let inputs = unspent + .values() + .map(|o| KeyedInput::new(o.commitment, o.value)) + .collect::>(); + + // TODO: note that the behaviour of this implementation does not allow for partial selection, needed by + // UtxoInputSelection::PreferConfidential. For now, we prevent running into this by only + // using SmallestFirst. + let result = input_selection::branch_and_bound::select(&inputs, amount, INPUT_LIMIT).ok_or( + StealthOutputsApiError::InputSelectionFailed { + details: "Failed to select inputs using branch and bound algorithm".to_string(), + }, + )?; + + let outputs = result + .selected_keys() + .iter() + .take(INPUT_LIMIT) + .map(|selected| { + unspent + .remove(*selected) + .expect("selected an output not in the input key set") + }) + .collect::>(); + + // Lock the selected outputs + tx.stealth_outputs_lock_many(resource_address, result.selected_keys(), locked_by_id)?; + + Ok((outputs, result.total_value())) + }, + } } pub fn add_output(&self, output: &StealthOutputModel) -> Result<(), StealthOutputsApiError> { @@ -195,7 +275,7 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { KeyBranch::ViewOnlyKey, view_only_key_id, &nonce, - // We dont need to decrypt the memo to spend the output + // We don't need to decrypt the memo to spend the output true, )?; @@ -218,10 +298,10 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { &self, account_address: &ComponentAddress, exclude_locked: bool, - ) -> Result, StealthOutputsApiError> { + ) -> Result, StealthOutputsApiError> { let balance = self .store - .with_read_tx(|tx| tx.stealth_outputs_get_unspent_by_account(account_address, exclude_locked))?; + .with_read_tx(|tx| tx.stealth_outputs_get_unspent_by_account(account_address, None, exclude_locked))?; Ok(balance) } @@ -558,30 +638,16 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { pub fn create_output_witness( &self, destination: &RistrettoOotleAddress, - amount: Amount, + amount: u64, resource_address: &ResourceAddress, resource_view_key: Option, memo: Option<&Memo>, ) -> Result { - if !amount.is_positive() { - return Err(StealthOutputsApiError::InvalidParameter { - param: "amount", - reason: format!("create_output_witness: Amount must be positive, got {}", amount), - }); - } - let mask = self.key_manager_api.next_key(KeyBranch::StealthMask)?; let (nonce_secret, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); let encrypted_data = self.crypto_api.encrypt_value_and_mask( - amount - .to_u64_checked() - .ok_or_else(|| StealthOutputsApiError::InvalidParameter { - param: "amount", - reason: "Stealth amount exceeds u64::MAX. This is currently a limitation due to the format of \ - EncryptedData" - .to_string(), - })?, + amount, &mask.key, destination.view_only_key(), &nonce_secret, @@ -645,9 +711,9 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { }) .collect::, _>>()?; let total_input_amount = - unblinded_inputs.iter().map(|i| i.value()).sum::() + params.input_revealed_amount; + unblinded_inputs.iter().map(|i| Amount::from(i.value())).sum::() + params.input_revealed_amount; let total_output_amount = - outputs.iter().map(|o| o.witness.amount).sum::() + params.output_revealed_amount; + outputs.iter().map(|o| Amount::from(o.witness.amount)).sum::() + params.output_revealed_amount; if total_input_amount != total_output_amount { return Err(StealthOutputsApiError::InvalidParameter { param: "inputs/outputs", @@ -725,6 +791,8 @@ pub enum StealthOutputsApiError { Crypto(#[from] StealthCryptoApiError), #[error("Insufficient funds")] InsufficientFunds, + #[error("Input selection error: {details}")] + InputSelectionFailed { details: String }, #[error("Key manager error: {0}")] KeyManager(#[from] KeyManagerApiError), #[error("Accounts API error: {0}")] diff --git a/crates/wallet/sdk/src/apis/stealth_transfer/api.rs b/crates/wallet/sdk/src/apis/stealth_transfer/api.rs index 81a769438a..e556470628 100644 --- a/crates/wallet/sdk/src/apis/stealth_transfer/api.rs +++ b/crates/wallet/sdk/src/apis/stealth_transfer/api.rs @@ -139,7 +139,7 @@ where ); Ok(InputsToSpend { - inputs: inputs.into_iter().map(|i| i.into_spend_data()).collect(), + inputs, revealed: Amount::zero(), }) }, @@ -218,9 +218,7 @@ where utxo_amount_to_spend, )?; - let total_confidential_spent = Amount::sum_from_positive(inputs.iter().map(|i| i.value)) - // The wallet has somehow stored a negative amount, which should not happen. - .expect("BUG: an unblinded input amount was negative"); + let total_confidential_spent = inputs.iter().map(|i| Amount::from(i.value)).sum::(); if let Some(ref src_vault) = maybe_src_vault { self.locks_api @@ -239,7 +237,7 @@ where ); Ok(InputsToSpend { - inputs: inputs.into_iter().map(|i| i.into_spend_data()).collect(), + inputs, revealed: revealed_to_spend, }) }, @@ -251,9 +249,7 @@ where lock_id, )?; - let revealed_to_spend = spend_amount - .saturating_sub_positive(blinded_amount_locked) - .unwrap_or_else(Amount::zero); + let revealed_to_spend = spend_amount.saturating_sub_positive(blinded_amount_locked); if available_revealed_funds < revealed_to_spend { return Err(StealthTransferApiError::InsufficientFunds { @@ -280,7 +276,7 @@ where } Ok(InputsToSpend { - inputs: inputs.into_iter().map(|i| i.into_spend_data()).collect(), + inputs, revealed: revealed_to_spend, }) }, @@ -371,9 +367,25 @@ where let fee_inputs_to_spend = self.lock_fee_inputs(lock.id(), &owner_account, params.max_fee, params.fee_input_selection)?; + debug!( + target: LOG_TARGET, + "🔒️ Locked {} fee inputs for fee spending worth {} (max fee {})", + fee_inputs_to_spend.inputs.len(), + fee_inputs_to_spend.total_stealth_input_amount(), + params.max_fee, + ); + let fee_stealth_change_amt = fee_inputs_to_spend .total_stealth_input_amount() - .saturating_sub(params.max_fee.into()); + .saturating_sub_positive(params.max_fee.into()) + .to_u64_checked() + .ok_or_else(|| { + StealthTransferApiError::InvariantViolation { + // Technically, you could create multiple outputs, but for simplicity and because this is + // extremely unlikely to be needed, we only create one here + details: "Fee change amount exceeds u64".to_string(), + } + })?; // Generate fee change outputs if required let fee_change_output = Some(StealthOutputToCreate { @@ -381,7 +393,7 @@ where amount: fee_stealth_change_amt, memo: None, }) - .filter(|o| o.amount.is_positive()); + .filter(|o| o.amount > 0); // Figure out which signing key to use - if there are no revealed funds, which necessitate using an account // withdraw auth signature, then we can use a nonce key. @@ -498,7 +510,13 @@ where let change_output = Some(StealthOutputToCreate { owner_address, - amount: change_amount, + amount: change_amount + .to_u64_checked() + .ok_or_else(|| StealthTransferApiError::InvariantViolation { + // Technically, you could create multiple outputs, but for simplicity and because this is + // extremely unlikely to be needed, we only create one here + details: "Change amount exceeds u64".to_string(), + })?, memo: None, }); @@ -519,7 +537,7 @@ where outputs: outputs_to_create .into_iter() .chain(change_output) - .filter(|o| o.amount.is_positive()), + .filter(|o| o.amount > 0), output_revealed_amount: params.total_revealed_output_amount(), required_signer: required_signer_pk, })?; @@ -541,7 +559,11 @@ where &owner_account, params.resource_address, output, - change_amount, + change_amount + .to_u64_checked() + .ok_or_else(|| StealthTransferApiError::InvariantViolation { + details: "Change amount exceeds u64".to_string(), + })?, None, )?; } @@ -837,7 +859,7 @@ where account: &AccountWithAddress, resource_address: ResourceAddress, output: &StealthUnspentOutput, - value: Amount, + value: u64, memo: Option, ) -> Result<(), StealthTransferApiError> { self.outputs_api.add_output(&StealthOutputModel { diff --git a/crates/wallet/sdk/src/apis/stealth_transfer/params.rs b/crates/wallet/sdk/src/apis/stealth_transfer/params.rs index 9de2f0506f..6458eef51f 100644 --- a/crates/wallet/sdk/src/apis/stealth_transfer/params.rs +++ b/crates/wallet/sdk/src/apis/stealth_transfer/params.rs @@ -41,7 +41,7 @@ impl StealthTransferParams { }); } - let blinded_count = self.outputs.iter().filter(|o| o.blinded_amount.is_positive()).count(); + let blinded_count = self.outputs.iter().filter(|o| o.blinded_amount > 0).count(); if blinded_count > MAX_LAZY_BP_AGG_FACTORS { return Err(StealthTransferApiError::InvalidParameter { param: "outputs", @@ -53,13 +53,6 @@ impl StealthTransferParams { } for output in &self.outputs { - if output.blinded_amount.is_negative() { - return Err(StealthTransferApiError::InvalidParameter { - param: "blinded_output_amount", - reason: "Blinded output amount must be non-negative".to_string(), - }); - } - if output.revealed_amount.is_negative() { return Err(StealthTransferApiError::InvalidParameter { param: "revealed_output_amount", @@ -67,7 +60,7 @@ impl StealthTransferParams { }); } - if output.blinded_amount.is_zero() && output.revealed_amount.is_zero() { + if output.blinded_amount == 0 && output.revealed_amount.is_zero() { return Err(StealthTransferApiError::InvalidParameter { param: "blinded_output_amount and revealed_output_amount", reason: "At least one of the amounts must be greater than zero".to_string(), @@ -115,14 +108,14 @@ pub struct TransferOutput { /// Amount to spend to a revealed output pub revealed_amount: Amount, /// Amount to spend to a blinded output - pub blinded_amount: Amount, + pub blinded_amount: u64, /// Optional memo to include a memo in the output. This memo is encrypted and can only be read by the recipient. pub memo: Option, } impl TransferOutput { pub fn total_output_amount(&self) -> Amount { - self.revealed_amount + self.blinded_amount + self.revealed_amount + Amount::from(self.blinded_amount) } } diff --git a/crates/wallet/sdk/src/apis/stealth_transfer/types.rs b/crates/wallet/sdk/src/apis/stealth_transfer/types.rs index 1de76c98ee..731ef6647d 100644 --- a/crates/wallet/sdk/src/apis/stealth_transfer/types.rs +++ b/crates/wallet/sdk/src/apis/stealth_transfer/types.rs @@ -25,7 +25,7 @@ pub struct UnblindedInputToSpend { } impl UnblindedInputToSpend { - pub fn value(&self) -> Amount { + pub fn value(&self) -> u64 { self.witness.mask_and_value.value } } @@ -33,7 +33,7 @@ impl UnblindedInputToSpend { #[derive(Debug, Clone)] pub struct StealthOutputToCreate<'a> { pub owner_address: RistrettoOotleAddress, - pub amount: Amount, + pub amount: u64, pub memo: Option<&'a Memo>, } @@ -53,7 +53,7 @@ impl InputsToSpend { } pub fn total_stealth_input_amount(&self) -> Amount { - self.inputs.iter().map(|i| i.value).sum() + self.inputs.iter().map(|i| Amount::from(i.value)).sum() } } diff --git a/crates/wallet/sdk/src/models/input_selection/branch_and_bound.rs b/crates/wallet/sdk/src/models/input_selection/branch_and_bound.rs new file mode 100644 index 0000000000..1229cbb76a --- /dev/null +++ b/crates/wallet/sdk/src/models/input_selection/branch_and_bound.rs @@ -0,0 +1,400 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +use tari_template_lib::types::Amount; + +#[derive(Debug, Clone)] +pub struct KeyedInput { + key: K, + value: u64, +} + +impl KeyedInput { + pub fn new(key: K, value: u64) -> Self { + Self { key, value } + } + + pub fn key(&self) -> &K { + &self.key + } + + pub fn value(&self) -> u64 { + self.value + } +} + +pub struct SelectionResult<'a, K> { + total_value: Amount, + selected_keys: Vec<&'a K>, +} + +impl<'a, K> SelectionResult<'a, K> { + pub fn total_value(&self) -> Amount { + self.total_value + } + + pub fn selected_keys(&self) -> &[&'a K] { + &self.selected_keys + } +} + +struct State<'a, K> { + index: usize, + total: Amount, + selected: Vec<&'a K>, +} + +impl<'a, K> Clone for State<'a, K> { + fn clone(&self) -> Self { + State { + index: self.index, + total: self.total, + selected: self.selected.clone(), + } + } +} + +/// Find the smallest achievable sum >= target using an iterative branch-and-bound search. +/// This is simplified from the Bitcoin Core implementation because we do not take input weights and fees minimization +/// into account. +/// +/// # Arguments +/// * `inputs` - Available inputs to select from +/// * `target` - Target amount to reach +/// * `max_inputs` - Maximum number of inputs that can be selected (e.g., 1000) +pub fn select, K: Clone>( + inputs: &[KeyedInput], + target: A, + max_inputs: usize, +) -> Option> { + // Sort descending to improve pruning efficiency + // Collect references to avoid cloning keys/values unnecessarily + let mut items = inputs.iter().collect::>(); + items.sort_by(|a, b| b.value.cmp(&a.value)); + + let mut best_sum: Option = None; + let mut best_keys = Vec::new(); + let target = target.into(); + + // stack of states to explore + let mut stack = vec![State { + index: 0, + total: Amount::zero(), + selected: Vec::new(), + }]; + + while let Some(state) = stack.pop() { + // --- Pruning conditions --- + // Prune if we've exceeded the maximum number of inputs + if state.selected.len() > max_inputs { + continue; + } + + if let Some(best) = best_sum { + // already have a better or equal solution + if best <= state.total { + continue; + } + } + + // if total already meets or exceeds target → potential solution + if target <= state.total { + match best_sum { + Some(best) if state.total < best => { + best_sum = Some(state.total); + best_keys = state.selected; + let change = state.total - target; + if change == 0 { + break; // optimal solution found + } + }, + None => { + best_sum = Some(state.total); + best_keys = state.selected; + }, + _ => {}, + } + continue; + } + + // if no more items, skip + if state.index >= items.len() { + continue; + } + + // upper bound: even if we add everything left, can we reach target? + let remaining_sum = items[state.index..] + .iter() + .map(|i| Amount::from(i.value)) + .sum::(); + if target > state.total + remaining_sum { + continue; // impossible to reach target → prune + } + + // --- Branch 1: include current item (only if we haven't reached max inputs) --- + if state.selected.len() < max_inputs { + let mut with = state.clone(); + with.total += Amount::from(items[state.index].value); + with.index += 1; + with.selected.push(&items[state.index].key); + stack.push(with); + } + + // --- Branch 2: skip current item --- + let mut without = state.clone(); + without.index += 1; + stack.push(without); + } + + best_sum.map(|total_value| SelectionResult { + total_value, + selected_keys: best_keys, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_finds_the_best_exact_fit() { + let inputs = vec![ + KeyedInput { key: "A", value: 500 }, + KeyedInput { key: "B", value: 600 }, + KeyedInput { key: "C", value: 1000 }, + ]; + let target = 1100; + + let result = select(&inputs, target, 1000).unwrap(); + assert_eq!(result.total_value, 1100); + assert_eq!(result.selected_keys.len(), 2); + } + + #[test] + fn test_empty_inputs() { + let inputs: Vec> = vec![]; + let target = 100; + + let result = select(&inputs, target, 1000); + assert!(result.is_none()); + } + + #[test] + fn test_insufficient_funds() { + let inputs = vec![KeyedInput { key: "A", value: 100 }, KeyedInput { key: "B", value: 200 }]; + let target = 500; + + let result = select(&inputs, target, 1000); + assert!(result.is_none()); + } + + #[test] + fn test_single_input_exact_match() { + let inputs = vec![KeyedInput { key: "A", value: 1000 }]; + let target = 1000; + + let result = select(&inputs, target, 1000).unwrap(); + assert_eq!(result.total_value, 1000); + assert_eq!(result.selected_keys.len(), 1); + assert_eq!(*result.selected_keys[0], "A"); + } + + #[test] + fn test_single_input_overshoot() { + let inputs = vec![KeyedInput { key: "A", value: 1500 }]; + let target = 1000; + + let result = select(&inputs, target, 1000).unwrap(); + assert_eq!(result.total_value, 1500); + assert_eq!(result.selected_keys.len(), 1); + assert_eq!(*result.selected_keys[0], "A"); + } + + #[test] + fn test_multiple_solutions_finds_best() { + let inputs = vec![ + KeyedInput { key: "A", value: 1000 }, + KeyedInput { key: "B", value: 500 }, + KeyedInput { key: "C", value: 600 }, + ]; + let target = 1100; + + let result = select(&inputs, target, 1000).unwrap(); + // Should prefer B+C (1100) over A alone (1000 is insufficient) + assert_eq!(result.total_value, 1100); + assert_eq!(result.selected_keys.len(), 2); + } + + #[test] + fn test_prefers_minimal_change() { + let inputs = vec![ + KeyedInput { key: "A", value: 1000 }, + KeyedInput { key: "B", value: 500 }, + KeyedInput { key: "C", value: 600 }, + KeyedInput { key: "D", value: 200 }, + ]; + let target = 900; + + let result = select(&inputs, target, 1000).unwrap(); + assert_eq!(result.total_value, 1000); + } + + #[test] + fn test_large_set_performance() { + let inputs: Vec> = (1..=20) + .map(|i| KeyedInput { + key: i, + value: i as u64 * 100, + }) + .collect(); + let target = 1500; + + let start = std::time::Instant::now(); + let result = select(&inputs, target, 1000); + let duration = start.elapsed(); + + // Should complete quickly even with larger input sets + assert!(duration.as_millis() < 1000); + + if let Some(selection) = result { + assert!(selection.total_value >= target); + assert!(!selection.selected_keys.is_empty()); + } + } + + #[test] + fn test_zero_target() { + let inputs = vec![KeyedInput { key: "A", value: 100 }, KeyedInput { key: "B", value: 200 }]; + let target = 0; + + let result = select(&inputs, target, 1000).unwrap(); + // Should return empty selection since 0 target is already met + assert_eq!(result.total_value, 0); + assert_eq!(result.selected_keys.len(), 0); + } + + #[test] + fn test_duplicate_values() { + let inputs = vec![ + KeyedInput { key: "A", value: 500 }, + KeyedInput { key: "B", value: 500 }, + KeyedInput { key: "C", value: 500 }, + ]; + let target = 1000; + + let result = select(&inputs, target, 1000).unwrap(); + assert_eq!(result.total_value, 1000); + assert_eq!(result.selected_keys.len(), 2); + } + + #[test] + fn test_sorting_behavior() { + let inputs = vec![ + KeyedInput { + key: "small", + value: 100, + }, + KeyedInput { + key: "large", + value: 1000, + }, + KeyedInput { + key: "medium", + value: 500, + }, + ]; + let target = 500; + + let result = select(&inputs, target, 1000).unwrap(); + // Algorithm should find the medium value (500) as exact match + assert_eq!(result.total_value, 500); + assert_eq!(result.selected_keys.len(), 1); + assert_eq!(*result.selected_keys[0], "medium"); + } + + #[test] + fn test_greedy_vs_optimal() { + let inputs = vec![ + KeyedInput { key: "A", value: 800 }, + KeyedInput { key: "B", value: 400 }, + KeyedInput { key: "C", value: 300 }, + ]; + let target = 700; + + let result = select(&inputs, target, 1000).unwrap(); + // Greedy would pick A (800), but optimal is B+C (700) + assert_eq!(result.total_value, 700); + assert_eq!(result.selected_keys.len(), 2); + } + + #[test] + fn test_max_inputs_limit_respected() { + let inputs = vec![ + KeyedInput { key: "A", value: 200 }, + KeyedInput { key: "B", value: 150 }, + KeyedInput { key: "C", value: 50 }, + KeyedInput { key: "D", value: 50 }, + KeyedInput { key: "E", value: 400 }, + ]; + let target = 300; + let max_inputs = 2; + + let result = select(&inputs, target, max_inputs).unwrap(); + // Should be able to reach 300 with A(200) + B(150) = 350 + assert_eq!(result.selected_keys.len(), 2); + assert_eq!(result.total_value, 350); + } + + #[test] + fn test_max_inputs_limit_prevents_solution() { + let inputs = vec![ + KeyedInput { key: "A", value: 100 }, + KeyedInput { key: "B", value: 100 }, + KeyedInput { key: "C", value: 100 }, + ]; + let target = 250; + let max_inputs = 2; // Need 3 inputs to reach target, but limited to 2 + + let result = select(&inputs, target, max_inputs); + assert!(result.is_none()); + } + + #[test] + fn test_max_inputs_limit_zero() { + let inputs = vec![KeyedInput { key: "A", value: 100 }, KeyedInput { key: "B", value: 200 }]; + let target = 100; + let max_inputs = 0; + + let result = select(&inputs, target, max_inputs); + // Can't select any inputs with max_inputs = 0, so target > 0 should fail + assert!(result.is_none()); + } + + #[test] + fn test_max_inputs_limit_one() { + let inputs = vec![ + KeyedInput { key: "A", value: 500 }, + KeyedInput { key: "B", value: 200 }, + KeyedInput { key: "C", value: 200 }, + ]; + let target = 400; + let max_inputs = 1; + + let result = select(&inputs, target, max_inputs).unwrap(); + assert_eq!(result.selected_keys.len(), 1); + assert!(result.total_value >= target); + // Should select the largest input (500) since it's the only way to meet target with 1 input + assert_eq!(*result.selected_keys[0], "A"); + } + + #[test] + fn test_max_inputs_larger_than_available() { + let inputs = vec![KeyedInput { key: "A", value: 100 }, KeyedInput { key: "B", value: 200 }]; + let target = 250; + let max_inputs = 10; // More than available inputs + + let result = select(&inputs, target, max_inputs).unwrap(); + assert_eq!(result.selected_keys.len(), 2); // Uses all available inputs + assert_eq!(result.total_value, 300); + } +} diff --git a/crates/wallet/sdk/src/models/input_selection/mod.rs b/crates/wallet/sdk/src/models/input_selection/mod.rs new file mode 100644 index 0000000000..d1df648b5b --- /dev/null +++ b/crates/wallet/sdk/src/models/input_selection/mod.rs @@ -0,0 +1,12 @@ +// Copyright 2025 The Tari Project +// SPDX-License-Identifier: BSD-3-Clause + +pub mod branch_and_bound; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputSelectionAlgorithm { + /// Select the smallest number of inputs that cover the required amount + SmallestFirst, + /// Branch and bound algorithm + BranchAndBound, +} diff --git a/crates/wallet/sdk/src/models/mod.rs b/crates/wallet/sdk/src/models/mod.rs index d10e431fb3..e2040e3147 100644 --- a/crates/wallet/sdk/src/models/mod.rs +++ b/crates/wallet/sdk/src/models/mod.rs @@ -7,6 +7,7 @@ mod confidential_output; mod config; mod epoch_birthday; mod event; +pub mod input_selection; mod key; mod lock_guard; mod non_fungible_tokens; diff --git a/crates/wallet/sdk/src/models/stealth_output.rs b/crates/wallet/sdk/src/models/stealth_output.rs index f72eee3679..065ee3e569 100644 --- a/crates/wallet/sdk/src/models/stealth_output.rs +++ b/crates/wallet/sdk/src/models/stealth_output.rs @@ -15,7 +15,7 @@ pub struct StealthOutputModel { pub owner_account: ComponentAddress, pub resource_address: ResourceAddress, pub commitment: PedersenCommitmentBytes, - pub value: Amount, + pub value: u64, 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 view_only_key_id: KeyId, @@ -48,15 +48,37 @@ impl StealthOutputModel { } } +#[derive(Debug, Clone)] +pub struct StealthOutputInfo { + pub resource_address: ResourceAddress, + pub commitment: PedersenCommitmentBytes, + pub public_nonce: RistrettoPublicKeyBytes, + pub encrypted_data: EncryptedData, + pub value: u64, + pub is_on_chain: bool, +} + #[derive(Debug, Clone)] pub struct InputSpendData { pub commitment: PedersenCommitmentBytes, pub public_nonce: RistrettoPublicKeyBytes, pub encrypted_data: EncryptedData, - pub value: Amount, + pub value: u64, pub is_on_chain: bool, } +impl From for InputSpendData { + fn from(info: StealthOutputInfo) -> Self { + Self { + commitment: info.commitment, + public_nonce: info.public_nonce, + encrypted_data: info.encrypted_data, + value: info.value, + is_on_chain: info.is_on_chain, + } + } +} + pub struct StealthBalance { pub balance: Amount, pub utxo_count: usize, diff --git a/crates/wallet/sdk/src/storage/reader.rs b/crates/wallet/sdk/src/storage/reader.rs index a5f7bcf8ce..ea440c50ed 100644 --- a/crates/wallet/sdk/src/storage/reader.rs +++ b/crates/wallet/sdk/src/storage/reader.rs @@ -23,6 +23,7 @@ use crate::{ OutputStatus, ResourceModel, StealthBalance, + StealthOutputInfo, StealthOutputModel, SubstateModel, TransactionStatus, @@ -128,8 +129,16 @@ pub trait WalletStoreReader { fn stealth_outputs_get_unspent_by_account( &mut self, account_addr: &ComponentAddress, + resource_address: Option<&ResourceAddress>, exclude_locked: bool, - ) -> Result, WalletStorageError>; + ) -> Result, WalletStorageError>; + + fn stealth_outputs_get_unspent_for_spending( + &mut self, + account_addr: &ComponentAddress, + resource_address: &ResourceAddress, + lock_id: WalletLockId, + ) -> Result, WalletStorageError>; fn stealth_outputs_get_locked_by_lock_id( &mut self, diff --git a/crates/wallet/sdk/src/storage/writer.rs b/crates/wallet/sdk/src/storage/writer.rs index 8f844c32e0..03c670a307 100644 --- a/crates/wallet/sdk/src/storage/writer.rs +++ b/crates/wallet/sdk/src/storage/writer.rs @@ -10,7 +10,7 @@ use tari_engine_types::{ use tari_ootle_common_types::{shard::Shard, Epoch, StateVersion, VersionedSubstateIdRef}; use tari_template_lib::{ models::{ComponentAddress, NonFungibleId, ResourceAddress, UtxoAddress, UtxoId, VaultId}, - prelude::{crypto::UtxoTag, Amount, RistrettoPublicKeyBytes, TemplateAddress}, + prelude::{crypto::UtxoTag, Amount, PedersenCommitmentBytes, RistrettoPublicKeyBytes, TemplateAddress}, }; use tari_transaction::{Transaction, TransactionId}; use webauthn_rs::prelude::Passkey; @@ -152,6 +152,13 @@ pub trait WalletStoreWriter: CommittableStore { resource_address: &ResourceAddress, lock_id: WalletLockId, ) -> Result; + + fn stealth_outputs_lock_many( + &mut self, + resource_address: &ResourceAddress, + utxos: &[&PedersenCommitmentBytes], + lock_id: WalletLockId, + ) -> Result<(), WalletStorageError>; fn stealth_outputs_insert(&mut self, output: &StealthOutputModel) -> Result<(), WalletStorageError>; fn stealth_outputs_mark_as_spent( &mut self, 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 b438879d79..2c9e6cc5eb 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 @@ -250,7 +250,7 @@ CREATE TABLE stealth_outputs owner_account_id INTEGER NOT NULL REFERENCES accounts (id), resource_address TEXT NOT NULL, commitment TEXT NOT NULL, - value TEXT NOT NULL, + value BIGINT NOT NULL, sender_public_nonce TEXT NOT NULL, -- Status can be "Unspent", "Spent", "Locked", "LockedUnconfirmed", "Invalid" status TEXT NOT NULL, diff --git a/crates/wallet/storage_sqlite/src/models/stealth_output.rs b/crates/wallet/storage_sqlite/src/models/stealth_output.rs index 832dced506..d98b104153 100644 --- a/crates/wallet/storage_sqlite/src/models/stealth_output.rs +++ b/crates/wallet/storage_sqlite/src/models/stealth_output.rs @@ -2,11 +2,13 @@ // SPDX-License-Identifier: BSD-3-Clause use diesel::dsl; -use tari_ootle_wallet_sdk::{models::StealthOutputModel, storage::WalletStorageError}; +use tari_ootle_wallet_sdk::{ + models::{StealthOutputInfo, StealthOutputModel}, + storage::WalletStorageError, +}; use tari_template_lib::{ models::ComponentAddress, types::{ - amount, crypto::{RistrettoPublicKeyBytes, UtxoTag}, EncryptedData, }, @@ -25,7 +27,7 @@ pub struct StealthOutput { pub owner_account_id: i32, pub resource_address: String, pub commitment: String, - pub value: String, + pub value: i64, pub sender_public_nonce: String, pub status: String, pub locked_at: Option, @@ -60,7 +62,7 @@ impl StealthOutput { item: "output commitment", details: "Corrupt db: invalid hex representation".to_string(), })?, - value: amount![&self.value], + value: self.value as u64, sender_public_nonce: RistrettoPublicKeyBytes::from_hex(&self.sender_public_nonce).map_err(|_| { WalletStorageError::DecodingError { operation: "try_into_output", @@ -96,6 +98,44 @@ impl StealthOutput { } } +impl TryFrom for StealthOutputInfo { + type Error = WalletStorageError; + + fn try_from(value: StealthOutput) -> Result { + Ok(StealthOutputInfo { + resource_address: value + .resource_address + .parse() + .map_err(|_| WalletStorageError::DecodingError { + operation: "try_into_output_info", + item: "output info", + details: format!("Corrupt db: invalid resource address '{}'", value.resource_address), + })?, + public_nonce: RistrettoPublicKeyBytes::from_hex(&value.sender_public_nonce).map_err(|_| { + WalletStorageError::DecodingError { + operation: "try_into_output_info", + item: "output info public nonce", + details: "Corrupt db: invalid hex representation".to_string(), + } + })?, + encrypted_data: EncryptedData::try_from(value.encrypted_data).map_err(|len| { + WalletStorageError::DecodingError { + operation: "try_into_output_info", + item: "output info encrypted data", + details: format!("Corrupt db: invalid encrypted data length {len}"), + } + })?, + commitment: deserialize_hex_try_from(&value.commitment).map_err(|_| WalletStorageError::DecodingError { + operation: "try_into_output_info", + item: "output info commitment", + details: "Corrupt db: invalid hex representation".to_string(), + })?, + value: value.value as u64, + is_on_chain: value.is_on_chain, + }) + } +} + #[derive(AsChangeset)] #[diesel(table_name = stealth_outputs)] pub(crate) struct StealthOutputUpdate<'a> { diff --git a/crates/wallet/storage_sqlite/src/reader.rs b/crates/wallet/storage_sqlite/src/reader.rs index 2a34141f44..af890707c4 100644 --- a/crates/wallet/storage_sqlite/src/reader.rs +++ b/crates/wallet/storage_sqlite/src/reader.rs @@ -39,6 +39,7 @@ use tari_ootle_wallet_sdk::{ OutputStatus, ResourceModel, StealthBalance, + StealthOutputInfo, StealthOutputModel, SubstateModel, TransactionStatus, @@ -919,8 +920,9 @@ impl WalletStoreReader for ReadTransaction<'_> { fn stealth_outputs_get_unspent_by_account( &mut self, account_addr: &ComponentAddress, + resource_address: Option<&ResourceAddress>, exclude_locked: bool, - ) -> Result, WalletStorageError> { + ) -> Result, WalletStorageError> { const OPERATION: &str = "stealth_outputs_get_all_by_account"; use crate::schema::{accounts, stealth_outputs}; @@ -936,6 +938,10 @@ impl WalletStoreReader for ReadTransaction<'_> { .filter(stealth_outputs::status.eq(OutputStatus::Unspent.as_key_str())) .into_boxed(); + if let Some(resource_address) = resource_address { + query = query.filter(stealth_outputs::resource_address.eq(resource_address.to_string())); + } + if exclude_locked { query = query.filter(stealth_outputs::lock_id.is_null()); } @@ -946,7 +952,44 @@ impl WalletStoreReader for ReadTransaction<'_> { rows.map(|row| { row.map_err(|e| WalletStorageError::general(OPERATION, e)) - .and_then(|row| row.try_convert(*account_addr)) + .and_then(|row| row.try_into()) + }) + .collect() + } + + fn stealth_outputs_get_unspent_for_spending( + &mut self, + account_addr: &ComponentAddress, + resource_address: &ResourceAddress, + lock_id: WalletLockId, + ) -> Result, WalletStorageError> { + const OPERATION: &str = "stealth_outputs_get_unspent_for_spending"; + use crate::schema::{accounts, stealth_outputs}; + + let rows = stealth_outputs::table + .filter(stealth_outputs::resource_address.eq(resource_address.to_string())) + .filter( + stealth_outputs::owner_account_id.eq(accounts::table + .select(accounts::id) + .filter(accounts::address.eq(account_addr.to_string())) + .limit(1) + .single_value() + .assume_not_null()), + ) + .filter( + stealth_outputs::status + .eq(OutputStatus::Unspent.as_key_str()) + // Also include outputs created within the transaction + .or(stealth_outputs::status + .eq(OutputStatus::LockedUnconfirmed.as_key_str()) + .and(stealth_outputs::lock_id.eq(lock_id))), + ) + .load_iter::(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + rows.map(|row| { + row.map_err(|e| WalletStorageError::general(OPERATION, e)) + .and_then(|row| row.try_into()) }) .collect() } diff --git a/crates/wallet/storage_sqlite/src/schema.rs b/crates/wallet/storage_sqlite/src/schema.rs index 0a6309f9b7..ee96478a9a 100644 --- a/crates/wallet/storage_sqlite/src/schema.rs +++ b/crates/wallet/storage_sqlite/src/schema.rs @@ -153,7 +153,7 @@ diesel::table! { owner_account_id -> Integer, resource_address -> Text, commitment -> Text, - value -> Text, + value -> BigInt, sender_public_nonce -> Text, status -> Text, locked_at -> Nullable, diff --git a/crates/wallet/storage_sqlite/src/writer.rs b/crates/wallet/storage_sqlite/src/writer.rs index 7a1e803aed..50c441ecd2 100644 --- a/crates/wallet/storage_sqlite/src/writer.rs +++ b/crates/wallet/storage_sqlite/src/writer.rs @@ -26,7 +26,14 @@ use tari_engine_types::{ resource::Resource, substate::{SubstateDiff, SubstateId}, }; -use tari_ootle_common_types::{optional::Optional, shard::Shard, Epoch, StateVersion, VersionedSubstateIdRef}; +use tari_ootle_common_types::{ + displayable::Displayable, + optional::Optional, + shard::Shard, + Epoch, + StateVersion, + VersionedSubstateIdRef, +}; use tari_ootle_wallet_sdk::{ models::{ AccountUpdate, @@ -1249,6 +1256,43 @@ impl WalletStoreWriter for WriteTransaction<'_> { Ok(output) } + fn stealth_outputs_lock_many( + &mut self, + resource_address: &ResourceAddress, + utxos: &[&PedersenCommitmentBytes], + lock_id: WalletLockId, + ) -> Result<(), WalletStorageError> { + const OPERATION: &str = "stealth_outputs_lock_many"; + use crate::schema::stealth_outputs; + + let num_rows = diesel::update(stealth_outputs::table) + .set(( + stealth_outputs::status.eq(OutputStatus::LockedForSpend.as_key_str()), + stealth_outputs::lock_id.eq(lock_id), + stealth_outputs::locked_at.eq(dsl::now), + )) + .filter(stealth_outputs::resource_address.eq(resource_address.to_string())) + .filter(stealth_outputs::commitment.eq_any(utxos.iter().map(|id| serialize_hex(id.as_ref())))) + .execute(self.connection()) + .map_err(|e| WalletStorageError::general(OPERATION, e))?; + + if num_rows != utxos.len() { + return Err(WalletStorageError::NotFound { + operation: OPERATION, + entity: "stealth_output".to_string(), + key: format!( + "{}/{} found: resource_address={}, utxos={}", + num_rows, + utxos.len(), + resource_address, + utxos.display() + ), + }); + } + + Ok(()) + } + fn stealth_outputs_insert(&mut self, output: &StealthOutputModel) -> Result<(), WalletStorageError> { const OPERATION: &str = "stealth_outputs_insert"; use crate::schema::{accounts, stealth_outputs}; @@ -1263,7 +1307,7 @@ impl WalletStoreWriter for WriteTransaction<'_> { .assume_not_null()), stealth_outputs::resource_address.eq(output.resource_address.to_string()), stealth_outputs::commitment.eq(output.commitment.to_hex()), - stealth_outputs::value.eq(output.value.to_string()), + stealth_outputs::value.eq(output.value as i64), stealth_outputs::sender_public_nonce.eq(serialize_hex(output.sender_public_nonce)), 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()?), diff --git a/integration_tests/src/wallet_daemon_client.rs b/integration_tests/src/wallet_daemon_client.rs index 2655aa51cd..2759d42b94 100644 --- a/integration_tests/src/wallet_daemon_client.rs +++ b/integration_tests/src/wallet_daemon_client.rs @@ -103,7 +103,7 @@ pub async fn transfer_stealth( world: &mut TariWorld, source_account_name: String, dest_account_name: String, - amount: Amount, + amount: u64, wallet_daemon_name: String, outputs_name: String, resource_address: ResourceAddress, diff --git a/integration_tests/tests/steps/wallet_daemon.rs b/integration_tests/tests/steps/wallet_daemon.rs index a2d292b968..8891fd72c5 100644 --- a/integration_tests/tests/steps/wallet_daemon.rs +++ b/integration_tests/tests/steps/wallet_daemon.rs @@ -436,7 +436,7 @@ async fn when_i_create_transfer_proof_via_wallet_daemon( world, source_account_name, dest_account_name, - amount.into(), + amount, wallet_daemon_name, outputs_name, // TODO: support for custom stealth resources @@ -461,7 +461,7 @@ async fn when_stealth_transfer_via_wallet_daemon( world, account_name, destination_acc_name, - amount.into(), + amount, wallet_daemon_name, outputs_name, XTR, diff --git a/utilities/traffic-sim/src/sim.rs b/utilities/traffic-sim/src/sim.rs index fac6e0cf47..614cf53c5f 100644 --- a/utilities/traffic-sim/src/sim.rs +++ b/utilities/traffic-sim/src/sim.rs @@ -229,7 +229,7 @@ impl TrafficSim { badge_usage: Default::default(), transfers: vec![StealthTransfer { destination_address: receiver_address.address().clone(), - blinded_output_amount: amount_to_send.into(), + blinded_output_amount: amount_to_send, revealed_output_amount: Default::default(), output_memo: Some(Memo::new_message(format!("Transfer {id}: {amount_to_send}")).unwrap()), }], @@ -457,7 +457,7 @@ impl TrafficSim { outputs: vec![TransferOutput { address: account.address().clone(), revealed_amount: Amount::zero(), - blinded_amount: fund_amount.into(), + blinded_amount: fund_amount, memo: Some(Memo::new_message(format!("Initial Funding: {fund_amount}")).unwrap()), }], }], From 0dc065e22e1e6a0e0b826b469374f0c41948e36d Mon Sep 17 00:00:00 2001 From: Stan Bondi Date: Mon, 10 Nov 2025 11:49:00 +0400 Subject: [PATCH 2/2] review comments --- .../tari_walletd/src/handlers/confidential.rs | 55 +++++++++---------- .../ProofsGenerateRequest.ts | 2 +- clients/wallet_daemon_client/src/types.rs | 2 +- crates/wallet/sdk/src/apis/stealth_outputs.rs | 17 +++--- crates/wallet/storage_sqlite/src/reader.rs | 3 + 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/applications/tari_walletd/src/handlers/confidential.rs b/applications/tari_walletd/src/handlers/confidential.rs index 6ba50ad0c5..027db14831 100644 --- a/applications/tari_walletd/src/handlers/confidential.rs +++ b/applications/tari_walletd/src/handlers/confidential.rs @@ -67,14 +67,12 @@ pub async fn handle_create_transfer_proof( .get_vault_by_resource(account.component_address(), &req.resource_address)?; let lock = sdk.locks_api().create_lock_with_timeout(Duration::from_secs(5 * 60))?; - let amount_to_transfer = Amount::from(req.confidential_amount) - .checked_add(req.reveal_amount) - .ok_or_else(|| { - invalid_request(format!( - "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", - req.confidential_amount, req.reveal_amount - )) - })?; + let amount_to_transfer = req.confidential_amount.checked_add(req.reveal_amount).ok_or_else(|| { + invalid_request(format!( + "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", + req.confidential_amount, req.reveal_amount + )) + })?; // Lock inputs we're going to spend let (inputs, total_input_value) = sdk.confidential_outputs_api() @@ -95,8 +93,15 @@ pub async fn handle_create_transfer_proof( let output_mask = sdk.key_manager_api().next_key(KeyBranch::ConfidentialMask)?; let (_, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); + let confidential_amount = req.confidential_amount.to_u64_checked().ok_or_else(|| { + invalid_request(format!( + "Confidential amount exceeds the maximum value supported in a single UTXO. Amount: {}", + req.confidential_amount + )) + })?; + let encrypted_data = sdk.confidential_crypto_api().encrypt_value_and_mask( - req.confidential_amount, + confidential_amount, &output_mask.key, &public_nonce, &account_key.secret, @@ -112,29 +117,14 @@ pub async fn handle_create_transfer_proof( ) })?; - let output_statement = UnblindedOutputWitness { - amount: req.confidential_amount, - mask: output_mask.key, - sender_public_nonce: public_nonce, - minimum_value_promise: 0, - encrypted_data, - resource_view_key: resource_view_key.clone(), - }; - - let spend_amount = Amount::from(req.confidential_amount) - .checked_sub(req.reveal_amount) + let change_amount = total_input_value + .checked_sub_positive(req.confidential_amount) .ok_or_else(|| { invalid_request(format!( - "Amount to send must be greater than or equal to the amount to reveal. Amount = {}, Revealed = {}", - req.confidential_amount, req.reveal_amount + "Insufficient funds to send {}. Total input value = {}", + req.confidential_amount, total_input_value )) })?; - let change_amount = total_input_value.checked_sub_positive(spend_amount).ok_or_else(|| { - invalid_request(format!( - "Insufficient funds to send {}. Total input value = {}", - req.confidential_amount, total_input_value - )) - })?; let change_amount_u64 = change_amount.to_u64_checked().ok_or_else(|| { invalid_request(format!( "Change value exceeds the maximum value supported in a single UTXO. Change: {}. Total input value = {}", @@ -142,6 +132,15 @@ pub async fn handle_create_transfer_proof( )) })?; + let output_statement = UnblindedOutputWitness { + amount: confidential_amount, + mask: output_mask.key, + sender_public_nonce: public_nonce, + minimum_value_promise: 0, + encrypted_data, + resource_view_key: resource_view_key.clone(), + }; + let maybe_change_statement = if change_amount_u64 > 0 { let change_mask = sdk.key_manager_api().next_key(KeyBranch::ConfidentialMask)?; let (_, public_nonce) = RistrettoPublicKey::random_keypair(&mut OsRng); diff --git a/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts b/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts index 676a24681a..f708a9f878 100644 --- a/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts +++ b/bindings/src/types/wallet-daemon-client/ProofsGenerateRequest.ts @@ -6,7 +6,7 @@ import type { RistrettoPublicKeyBytes } from "../RistrettoPublicKeyBytes"; import type { ComponentAddressOrName } from "./ComponentAddressOrName"; export type ProofsGenerateRequest = { - confidential_amount: bigint; + confidential_amount: Amount; reveal_amount: Amount; account: ComponentAddressOrName | null; resource_address: ResourceAddress; diff --git a/clients/wallet_daemon_client/src/types.rs b/clients/wallet_daemon_client/src/types.rs index f278cba6da..99936ef34e 100644 --- a/clients/wallet_daemon_client/src/types.rs +++ b/clients/wallet_daemon_client/src/types.rs @@ -490,7 +490,7 @@ pub struct AccountsTransferResponse { #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "wallet-daemon-client/"))] pub struct ProofsGenerateRequest { - pub confidential_amount: u64, + pub confidential_amount: Amount, pub reveal_amount: Amount, #[serde(deserialize_with = "opt_string_or_struct")] pub account: Option, diff --git a/crates/wallet/sdk/src/apis/stealth_outputs.rs b/crates/wallet/sdk/src/apis/stealth_outputs.rs index 2674c80f2e..71eb679670 100644 --- a/crates/wallet/sdk/src/apis/stealth_outputs.rs +++ b/crates/wallet/sdk/src/apis/stealth_outputs.rs @@ -165,21 +165,20 @@ impl<'a, TStore: WalletStore> StealthOutputsApi<'a, TStore> { let mut total_output_amount = Amount::zero(); let mut outputs = Vec::new(); while total_output_amount < amount { + if outputs.len() >= INPUT_LIMIT { + warn!( + target: LOG_TARGET, + "Reached maximum input limit of {} when locking outputs.", + INPUT_LIMIT + ); + break; + } let output = tx .stealth_outputs_lock_smallest_amount(account_address, resource_address, locked_by_id) .optional()?; match output { Some(output) => { total_output_amount += Amount::from(output.value); - if outputs.len() >= INPUT_LIMIT { - warn!( - target: LOG_TARGET, - "Reached maximum input limit of {} when locking outputs.", - INPUT_LIMIT - ); - break; - } - outputs.push(output); }, None => { diff --git a/crates/wallet/storage_sqlite/src/reader.rs b/crates/wallet/storage_sqlite/src/reader.rs index af890707c4..9206c945c8 100644 --- a/crates/wallet/storage_sqlite/src/reader.rs +++ b/crates/wallet/storage_sqlite/src/reader.rs @@ -984,6 +984,9 @@ impl WalletStoreReader for ReadTransaction<'_> { .eq(OutputStatus::LockedUnconfirmed.as_key_str()) .and(stealth_outputs::lock_id.eq(lock_id))), ) + .filter(stealth_outputs::owner_key_id.is_not_null()) + .filter(stealth_outputs::is_burnt.eq(false)) + .filter(stealth_outputs::is_frozen.eq(false)) .load_iter::(self.connection()) .map_err(|e| WalletStorageError::general(OPERATION, e))?;