diff --git a/docs/accounts/balance/README.md b/docs/accounts/balance/README.md index 903b59311..15e678b8b 100644 --- a/docs/accounts/balance/README.md +++ b/docs/accounts/balance/README.md @@ -16,6 +16,7 @@ description: >- | `account_block_height` | string \(uint64\) | The scanned local block count for this account. This value will never be greater than `local_block_height`. At fully synced, it will match `network_block_height`. | `is_synced` | boolean | Whether the account is synced with the `network_block_height`. Balances may not appear correct if the account is still syncing. | | `unspent_pmob` | string \(uint64\) | Unspent pico MOB for this account at the current `account_block_height`. If the account is syncing, this value may change. | +| `max_spendable_pmob` | string \(uint64\) | Maximum pico MOB that can be sent in a single transaction for account at the current `account_block_height`. If the account is syncing, this value may change. It is the sum of the 16 (maximum number of inputs) largest spendable txos, minus the transaction fee. | | `pending_pmob` | string \(uint64\) | Pending, out-going pico MOB. The pending value will clear once the ledger processes the outgoing TXOs. The `pending_pmob` will reflect the change. | | `spent_pmob` | string \(uint64\) | Spent pico MOB. This is the sum of all the TXOs in the wallet which have been spent. | | `secreted_pmob` | string \(uint64\) | Secreted \(minted\) pico MOB. This is the sum of all the TXOs which have been created in the wallet for outgoing transactions. | @@ -31,6 +32,7 @@ description: >- "network_block_height": "152918", "object": "balance", "orphaned_pmob": "0", + "max_spendable_pmob": "0", "pending_pmob": "0", "secreted_pmob": "0", "spent_pmob": "0", diff --git a/docs/accounts/balance/get_balance_for_account.md b/docs/accounts/balance/get_balance_for_account.md index 4fd3125a5..52e866d6b 100644 --- a/docs/accounts/balance/get_balance_for_account.md +++ b/docs/accounts/balance/get_balance_for_account.md @@ -38,6 +38,7 @@ description: Get the current balance for a given account. "account_block_height": "152003", "is_synced": false, "unspent_pmob": "110000000000000000", + "max_spendable_pmob": "110000000000000000", "pending_pmob": "0", "spent_pmob": "0", "secreted_pmob": "0", diff --git a/docs/accounts/balance/get_balance_for_address.md b/docs/accounts/balance/get_balance_for_address.md index 581a90139..f9cb09567 100644 --- a/docs/accounts/balance/get_balance_for_address.md +++ b/docs/accounts/balance/get_balance_for_address.md @@ -35,6 +35,7 @@ description: Get the current balance for a given address. "account_block_height": "152961", "is_synced": true, "unspent_pmob": "11881402222024", + "max_spendable_pmob": "11881402222024", "pending_pmob": "0", "spent_pmob": "84493835554166", "secreted_pmob": "0", diff --git a/full-service/src/db/txo.rs b/full-service/src/db/txo.rs index f2f550687..a5e18be82 100644 --- a/full-service/src/db/txo.rs +++ b/full-service/src/db/txo.rs @@ -11,7 +11,9 @@ use mc_mobilecoind::payments::TxProposal; use mc_transaction_core::{ constants::MAX_INPUTS, ring_signature::KeyImage, + tokens::Mob, tx::{TxOut, TxOutConfirmationNumber}, + Token, }; use std::fmt; @@ -53,6 +55,11 @@ pub struct ProcessedTxProposalOutput { pub txo_type: String, } +pub struct SpendableTxosResult { + pub spendable_txos: Vec, + pub max_spendable_in_wallet: u128, +} + pub trait TxoModel { /// Upserts a received Txo. /// @@ -163,6 +170,13 @@ pub trait TxoModel { conn: &Conn, ) -> Result, WalletDbError>; + fn list_spendable( + account_id_hex: &str, + max_spendable_value: Option, + assigned_subaddress_b58: Option<&str>, + conn: &Conn, + ) -> Result; + fn list_secreted(account_id_hex: &str, conn: &Conn) -> Result, WalletDbError>; fn list_orphaned(account_id_hex: &str, conn: &Conn) -> Result, WalletDbError>; @@ -720,26 +734,33 @@ impl TxoModel for Txo { Ok(txos) } - fn select_unspent_txos_for_value( + fn list_spendable( account_id_hex: &str, - target_value: u64, max_spendable_value: Option, - pending_tombstone_block_index: Option, + assigned_subaddress_b58: Option<&str>, conn: &Conn, - ) -> Result, WalletDbError> { + ) -> Result { use crate::db::schema::txos; - let spendable_txos: Vec = txos::table + // The SQLite database cannot filter effectively on a u64 value, so filter for + // maximum value in memory. + let results = txos::table .filter(txos::spent_block_index.is_null()) .filter(txos::pending_tombstone_block_index.is_null()) .filter(txos::subaddress_index.is_not_null()) .filter(txos::key_image.is_not_null()) - .filter(txos::received_account_id_hex.eq(account_id_hex)) - .order_by(txos::value.desc()) - .load(conn)?; + .filter(txos::received_account_id_hex.eq(account_id_hex)); - // The SQLite database cannot filter effectively on a u64 value, so filter for - // maximum value in memory. - let mut spendable_txos = if let Some(msv) = max_spendable_value { + let spendable_txos: Vec = if let Some(subaddress_b58) = assigned_subaddress_b58 { + let subaddress = AssignedSubaddress::get(subaddress_b58, conn)?; + results + .filter(txos::subaddress_index.eq(subaddress.subaddress_index)) + .order_by(txos::value.desc()) + .load(conn)? + } else { + results.order_by(txos::value.desc()).load(conn)? + }; + + let spendable_txos = if let Some(msv) = max_spendable_value { spendable_txos .into_iter() .filter(|txo| (txo.value as u64) <= msv) @@ -748,30 +769,57 @@ impl TxoModel for Txo { spendable_txos }; - if spendable_txos.is_empty() { - return Err(WalletDbError::NoSpendableTxos); - } - // The maximum spendable is limited by the maximal number of inputs we can use. // Since the txos are sorted by decreasing value, this is the maximum // value we can possibly spend in one transaction. // Note, u128::Max = 340_282_366_920_938_463_463_374_607_431_768_211_455, which // is far beyond the total number of pMOB in the MobileCoin system // (250_000_000_000_000_000_000) - let max_spendable_in_wallet: u128 = spendable_txos + let mut max_spendable_in_wallet: u128 = spendable_txos .iter() .take(MAX_INPUTS as usize) .map(|utxo| (utxo.value as u64) as u128) .sum(); + + if max_spendable_in_wallet > Mob::MINIMUM_FEE as u128 { + max_spendable_in_wallet = max_spendable_in_wallet - Mob::MINIMUM_FEE as u128; + } else { + max_spendable_in_wallet = 0; + } + + Ok(SpendableTxosResult { + spendable_txos, + max_spendable_in_wallet, + }) + } + + fn select_unspent_txos_for_value( + account_id_hex: &str, + // target_value includes the network fee + target_value: u64, + max_spendable_value: Option, + pending_tombstone_block_index: Option, + conn: &Conn, + ) -> Result, WalletDbError> { + let SpendableTxosResult { + mut spendable_txos, + max_spendable_in_wallet, + } = Txo::list_spendable(account_id_hex, max_spendable_value, None, conn)?; + + if spendable_txos.is_empty() { + return Err(WalletDbError::NoSpendableTxos); + } + // If we're trying to spend more than we have in the wallet, we may need to // defrag - if target_value as u128 > max_spendable_in_wallet { + if target_value as u128 > max_spendable_in_wallet + Mob::MINIMUM_FEE as u128 { // See if we merged the UTXOs we would be able to spend this amount. let total_unspent_value_in_wallet: u128 = spendable_txos .iter() .map(|utxo| (utxo.value as u64) as u128) .sum(); - if total_unspent_value_in_wallet >= target_value as u128 { + + if total_unspent_value_in_wallet >= (target_value + Mob::MINIMUM_FEE) as u128 { return Err(WalletDbError::InsufficientFundsFragmentedTxos); } else { return Err(WalletDbError::InsufficientFundsUnderMaxSpendable(format!( @@ -1304,6 +1352,7 @@ mod tests { None, &wallet_db.get_conn().unwrap(), ); + match res { Err(WalletDbError::InsufficientFundsUnderMaxSpendable(_)) => {} Ok(_) => panic!("Should error with InsufficientFundsUnderMaxSpendable"), @@ -1790,6 +1839,134 @@ mod tests { ); } + #[test_with_logger] + fn test_list_spendable_more_txos(logger: Logger) { + let mut rng: StdRng = SeedableRng::from_seed([20u8; 32]); + + let db_test_context = WalletDbTestContext::default(); + let wallet_db = db_test_context.get_db_instance(logger); + let conn = wallet_db.get_conn().unwrap(); + + let root_id = RootIdentity::from_random(&mut rng); + let account_key = AccountKey::from(&root_id); + let (account_id, _address) = Account::create_from_root_entropy( + &root_id.root_entropy, + Some(0), + None, + None, + "", + "".to_string(), + "".to_string(), + "".to_string(), + &conn, + ) + .unwrap(); + + let txo_value = 100 * MOB; + + for i in 1..=20 { + let (_txo_id, _txo, _key_image) = + create_test_received_txo(&account_key, i, txo_value, i, &mut rng, &wallet_db); + } + + let SpendableTxosResult { + spendable_txos, + max_spendable_in_wallet, + } = Txo::list_spendable(&account_id.to_string(), None, None, &conn).unwrap(); + + assert_eq!(spendable_txos.len(), 20); + assert_eq!( + max_spendable_in_wallet as u64, + txo_value * 16 - Mob::MINIMUM_FEE + ); + } + + #[test_with_logger] + fn test_list_spendable_less_than_min_fee(logger: Logger) { + let mut rng: StdRng = SeedableRng::from_seed([20u8; 32]); + + let db_test_context = WalletDbTestContext::default(); + let wallet_db = db_test_context.get_db_instance(logger); + let conn = wallet_db.get_conn().unwrap(); + + let root_id = RootIdentity::from_random(&mut rng); + let account_key = AccountKey::from(&root_id); + let (account_id, _address) = Account::create_from_root_entropy( + &root_id.root_entropy, + Some(0), + None, + None, + "", + "".to_string(), + "".to_string(), + "".to_string(), + &conn, + ) + .unwrap(); + + let txo_value = 100; + + for i in 1..=10 { + let (_txo_id, _txo, _key_image) = + create_test_received_txo(&account_key, i, txo_value, i, &mut rng, &wallet_db); + } + + let SpendableTxosResult { + spendable_txos, + max_spendable_in_wallet, + } = Txo::list_spendable(&account_id.to_string(), None, None, &conn).unwrap(); + + assert_eq!(spendable_txos.len(), 10); + assert_eq!(max_spendable_in_wallet as u64, 0); + } + + #[test_with_logger] + fn test_list_spendable_max_spendable_value(logger: Logger) { + let mut rng: StdRng = SeedableRng::from_seed([20u8; 32]); + + let db_test_context = WalletDbTestContext::default(); + let wallet_db = db_test_context.get_db_instance(logger); + let conn = wallet_db.get_conn().unwrap(); + + let root_id = RootIdentity::from_random(&mut rng); + let account_key = AccountKey::from(&root_id); + let (account_id, _address) = Account::create_from_root_entropy( + &root_id.root_entropy, + Some(0), + None, + None, + "", + "".to_string(), + "".to_string(), + "".to_string(), + &conn, + ) + .unwrap(); + + let txo_value_low = 100 * MOB; + let txo_value_high = 200 * MOB; + + for i in 1..=5 { + let (_txo_id, _txo, _key_image) = + create_test_received_txo(&account_key, i, txo_value_low, i, &mut rng, &wallet_db); + } + for i in 1..=5 { + let (_txo_id, _txo, _key_image) = + create_test_received_txo(&account_key, i, txo_value_high, i, &mut rng, &wallet_db); + } + + let SpendableTxosResult { + spendable_txos, + max_spendable_in_wallet, + } = Txo::list_spendable(&account_id.to_string(), Some(100 * MOB), None, &conn).unwrap(); + + assert_eq!(spendable_txos.len(), 5); + assert_eq!( + max_spendable_in_wallet as u64, + txo_value_low * 5 - Mob::MINIMUM_FEE + ); + } + fn setup_select_unspent_txos_tests(logger: Logger, fragmented: bool) -> (AccountID, WalletDb) { let mut rng: StdRng = SeedableRng::from_seed([20u8; 32]); @@ -1831,8 +2008,14 @@ mod tests { } } else { for i in 1..=20 { - let (_txo_id, _txo, _key_image) = - create_test_received_txo(&account_key, i, i as u64, i, &mut rng, &wallet_db); + let (_txo_id, _txo, _key_image) = create_test_received_txo( + &account_key, + i, + i as u64 * MOB, + i, + &mut rng, + &wallet_db, + ); } } @@ -1840,20 +2023,21 @@ mod tests { } #[test_with_logger] - fn test_select_unspent_txos_target_value_equals_max_spendable_in_account(logger: Logger) { + fn test_select_unspent_txos_target_value_equal_max_spendable_in_account(logger: Logger) { + let target_value: u64 = 200 as u64 * MOB - Mob::MINIMUM_FEE; let (account_id, wallet_db) = setup_select_unspent_txos_tests(logger, false); let result = Txo::select_unspent_txos_for_value( &account_id.to_string(), - 200 as u64, + target_value, None, None, &wallet_db.get_conn().unwrap(), ) .unwrap(); assert_eq!(result.len(), 16); - let sum: i64 = result.iter().map(|x| x.value).sum(); - assert_eq!(200 as i64, sum); + let sum: u64 = result.iter().map(|x| x.value as u64).sum(); + assert_eq!(target_value, sum - Mob::MINIMUM_FEE); } #[test_with_logger] @@ -1862,7 +2046,7 @@ mod tests { let result = Txo::select_unspent_txos_for_value( &account_id.to_string(), - 201 as u64, + 201 as u64 * MOB, None, None, &wallet_db.get_conn().unwrap(), @@ -1885,9 +2069,7 @@ mod tests { &wallet_db.get_conn().unwrap(), ) .unwrap(); - assert_eq!(result.len(), 2); - let sum: i64 = result.iter().map(|x| x.value).sum(); - assert_eq!(3 as i64, sum); + assert_eq!(result.len(), 1); } #[test_with_logger] @@ -1896,7 +2078,7 @@ mod tests { let result = Txo::select_unspent_txos_for_value( &account_id.to_string(), - 500 as u64, + 500 as u64 * MOB, None, None, &wallet_db.get_conn().unwrap(), diff --git a/full-service/src/json_rpc/balance.rs b/full-service/src/json_rpc/balance.rs index 0aed9e927..ee7d9a267 100644 --- a/full-service/src/json_rpc/balance.rs +++ b/full-service/src/json_rpc/balance.rs @@ -35,6 +35,11 @@ pub struct Balance { /// If the account is syncing, this value may change. pub unspent_pmob: String, + /// The maximum amount of pico MOB that can be sent in a single transaction. + /// Equal to the sum of the 16 highest value txos - the network fee. + /// If the account is syncing, this value may change. + pub max_spendable_pmob: String, + /// Pending, out-going pico MOB. The pending value will clear once the /// ledger processes the outgoing txos. The available_pmob will reflect the /// change. @@ -63,6 +68,7 @@ impl From<&service::balance::Balance> for Balance { account_block_height: src.synced_blocks.to_string(), is_synced: src.synced_blocks == src.network_block_height, unspent_pmob: src.unspent.to_string(), + max_spendable_pmob: src.max_spendable.to_string(), pending_pmob: src.pending.to_string(), spent_pmob: src.spent.to_string(), secreted_pmob: src.secreted.to_string(), diff --git a/full-service/src/json_rpc/e2e.rs b/full-service/src/json_rpc/e2e.rs index db58f2e41..9411765eb 100644 --- a/full-service/src/json_rpc/e2e.rs +++ b/full-service/src/json_rpc/e2e.rs @@ -569,6 +569,15 @@ mod e2e { .to_string(), (42 * MOB).to_string() ); + assert_eq!( + balance + .get("max_spendable_pmob") + .unwrap() + .as_str() + .unwrap() + .to_string(), + (42 * MOB - Mob::MINIMUM_FEE).to_string() + ); } #[test_with_logger] @@ -1069,8 +1078,8 @@ mod e2e { "code": -32603, "message": "InternalError", "data": json!({ - "server_error": format!("TransactionBuilder(WalletDb(InsufficientFundsUnderMaxSpendable(\"Max spendable value in wallet: 100, but target value: {}\")))", 42 + Mob::MINIMUM_FEE), - "details": format!("Error building transaction: Wallet DB Error: Insufficient funds from Txos under max_spendable_value: Max spendable value in wallet: 100, but target value: {}", 42 + Mob::MINIMUM_FEE), + "server_error": format!("TransactionBuilder(WalletDb(InsufficientFundsUnderMaxSpendable(\"Max spendable value in wallet: 0, but target value: {}\")))", 42 + Mob::MINIMUM_FEE), + "details": format!("Error building transaction: Wallet DB Error: Insufficient funds from Txos under max_spendable_value: Max spendable value in wallet: 0, but target value: {}", 42 + Mob::MINIMUM_FEE), }) }), "jsonrpc": "2.0", diff --git a/full-service/src/service/balance.rs b/full-service/src/service/balance.rs index 959468a5c..7cd108318 100644 --- a/full-service/src/service/balance.rs +++ b/full-service/src/service/balance.rs @@ -80,6 +80,7 @@ pub struct Balance { pub network_block_height: u64, pub local_block_height: u64, pub synced_blocks: u64, + pub max_spendable: u128, } // The balance object for view-only-accounts @@ -155,7 +156,7 @@ where let account_id_hex = &account_id.to_string(); let conn = self.wallet_db.get_conn()?; - let (unspent, pending, spent, secreted, orphaned) = + let (unspent, max_spendable, pending, spent, secreted, orphaned) = Self::get_balance_inner(account_id_hex, &conn)?; let network_block_height = self.get_network_block_height()?; @@ -164,6 +165,7 @@ where Ok(Balance { unspent, + max_spendable, pending, spent, secreted, @@ -206,6 +208,10 @@ where let conn = self.wallet_db.get_conn()?; let assigned_address = AssignedSubaddress::get(address, &conn)?; + let max_spendable = + Txo::list_spendable(&assigned_address.account_id_hex, None, Some(address), &conn)? + .max_spendable_in_wallet; + // Orphaned txos have no subaddress assigned, so none of these txos can // be orphaned. let orphaned: u128 = 0; @@ -231,6 +237,7 @@ where Ok(Balance { unspent, + max_spendable, pending, spent, secreted, @@ -317,7 +324,9 @@ where fn get_balance_inner( account_id_hex: &str, conn: &Conn, - ) -> Result<(u128, u128, u128, u128, u128), BalanceServiceError> { + ) -> Result<(u128, u128, u128, u128, u128, u128), BalanceServiceError> { + let max_spendable = + Txo::list_spendable(account_id_hex, None, None, conn)?.max_spendable_in_wallet; // Note: We need to cast to u64 first, because i64 could have wrapped, then to // u128 let unspent = Txo::list_unspent(account_id_hex, None, conn)? @@ -341,7 +350,7 @@ where .map(|t| (t.value as u64) as u128) .sum::(); - let result = (unspent, pending, spent, secreted, orphaned); + let result = (unspent, max_spendable, pending, spent, secreted, orphaned); Ok(result) } } @@ -413,6 +422,8 @@ mod tests { // 3 accounts * 5_000 MOB * 12 blocks assert_eq!(account_balance.unspent, 180_000 * MOB as u128); + // 5_000 MOB per txo, max 16 txos input - network fee + assert_eq!(account_balance.max_spendable, 79999999600000000 as u128); assert_eq!(account_balance.pending, 0); assert_eq!(account_balance.spent, 0); assert_eq!(account_balance.secreted, 0); @@ -429,6 +440,7 @@ mod tests { .expect("Could not get balance for address"); assert_eq!(address_balance.unspent, 60_000 * MOB as u128); + assert_eq!(address_balance.max_spendable, 59999999600000000 as u128); assert_eq!(address_balance.pending, 0); assert_eq!(address_balance.spent, 0); assert_eq!(address_balance.secreted, 0); @@ -438,6 +450,7 @@ mod tests { .get_balance_for_address(&address.assigned_subaddress_b58) .expect("Could not get balance for address"); assert_eq!(address_balance2.unspent, 60_000 * MOB as u128); + assert_eq!(address_balance2.max_spendable, 59999999600000000 as u128); assert_eq!(address_balance2.pending, 0); assert_eq!(address_balance2.spent, 0); assert_eq!(address_balance2.secreted, 0);