diff --git a/.github/package-filters/rs-packages-no-workflows.yml b/.github/package-filters/rs-packages-no-workflows.yml index 9b361d9ec14..dee5954c634 100644 --- a/.github/package-filters/rs-packages-no-workflows.yml +++ b/.github/package-filters/rs-packages-no-workflows.yml @@ -99,3 +99,7 @@ wasm-sdk: - packages/wasm-sdk/src/** - packages/wasm-sdk/Cargo.toml - *sdk + +rs-platform-wallet: + - packages/rs-platform-wallet/** + - *sdk diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index da128830869..74824944908 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -21,6 +21,53 @@ jobs: with: clean: false + - name: Free runner disk space + run: | + set -eo pipefail + + echo "Disk before cleanup:" + df -h / "$GITHUB_WORKSPACE" "$RUNNER_TEMP" "${TMPDIR:-/tmp}" || true + + RUNNER_ROOT=$(cd "$GITHUB_WORKSPACE/../../.." && pwd) + echo "Runner root: $RUNNER_ROOT" + + if [ -d "$RUNNER_ROOT/_diag" ]; then + echo "Runner diagnostics before cleanup:" + du -sh "$RUNNER_ROOT/_diag" || true + + find "$RUNNER_ROOT/_diag" -type f \( -name '*.log' -o -name '*.trace' \) -mtime +7 -print -exec rm -f {} + || true + find "$RUNNER_ROOT/_diag/blocks" "$RUNNER_ROOT/_diag/pages" -type f -mtime +2 -print -exec rm -f {} + 2>/dev/null || true + + echo "Runner diagnostics after cleanup:" + du -sh "$RUNNER_ROOT/_diag" || true + fi + + for dir in "$RUNNER_ROOT/_work/_temp" "${TMPDIR:-}"; do + if [ -n "$dir" ] && [ -d "$dir" ]; then + echo "Cleaning stale temp files in $dir" + find "$dir" -mindepth 1 -mtime +1 -print -exec rm -rf {} + 2>/dev/null || true + fi + done + + for dir in target/llvm-cov-target target/nextest; do + if [ -d "$dir" ]; then + echo "Removing stale Rust test artifact cache: $dir" + du -sh "$dir" || true + rm -rf "$dir" + fi + done + + AVAILABLE_KB=$(df -Pk "$GITHUB_WORKSPACE" | awk 'NR==2 {print $4}') + MIN_FREE_KB=$((80 * 1024 * 1024)) + echo "Available workspace space: ${AVAILABLE_KB}KB" + if [ "${AVAILABLE_KB:-0}" -lt "$MIN_FREE_KB" ]; then + echo "Less than 80GB available; cleaning Rust target cache" + cargo clean || rm -rf target + fi + + echo "Disk after cleanup:" + df -h / "$GITHUB_WORKSPACE" "$RUNNER_TEMP" "${TMPDIR:-/tmp}" || true + - name: Clean target if too large run: | SIZE=$(du -sm target 2>/dev/null | cut -f1 || true) @@ -530,4 +577,3 @@ jobs: fi done echo "No immutable/append_only structure violations found" - diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index b2d9761ac2b..ec58d3264a6 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -129,8 +129,8 @@ async fn build_core_changeset( addresses_derived, .. } => { - // Derive UTXO deltas BEFORE moving the record into `records` - // so we still have the per-record borrows. + // Derive UTXO deltas before moving the record into `records` + // so the per-record borrows are still live. CoreChangeSet { new_utxos: derive_new_utxos(record), spent_utxos: derive_spent_utxos(record), diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 006e9b01331..af28dd705a8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,3 +1,5 @@ +use dpp::address_funds::PlatformAddress; +use dpp::fee::Credits; use dpp::identifier::Identifier; use key_wallet::Network; @@ -72,6 +74,18 @@ pub enum PlatformWalletError { #[error("Address operation failed: {0}")] AddressOperation(String), + #[error("{}", format_no_selectable_inputs(funded_outputs, *sub_min_count, *sub_min_aggregate, *min_input_amount))] + NoSelectableInputs { + /// Funded addresses dropped by the input-equals-output filter. + funded_outputs: Vec, + /// Number of addresses with a positive balance below `min_input_amount`. + sub_min_count: usize, + /// Aggregate of those sub-minimum balances. + sub_min_aggregate: Credits, + /// Per-input minimum from the active platform version. + min_input_amount: Credits, + }, + #[error("Platform address not found in wallet: {0}")] AddressNotFound(String), @@ -137,6 +151,37 @@ pub enum PlatformWalletError { ShieldedKeyDerivation(String), } +/// Render the `NoSelectableInputs` diagnostic, omitting whichever failure +/// shape did not contribute (sub-min dust or funded-but-also-output) so +/// operators don't see misleading zero-valued fields. +fn format_no_selectable_inputs( + funded_outputs: &[PlatformAddress], + sub_min_count: usize, + sub_min_aggregate: Credits, + min_input_amount: Credits, +) -> String { + let mut parts: Vec = Vec::with_capacity(2); + if !funded_outputs.is_empty() { + parts.push(format!("funded_outputs={funded_outputs:?}")); + } + if sub_min_count > 0 { + parts.push(format!( + "sub_min_count={sub_min_count} sub_min_aggregate={sub_min_aggregate} credits" + )); + } + let body = if parts.is_empty() { + // Detector returns Some only when at least one shape applies; defensive. + "no funded inputs survived the auto-selection filter".to_string() + } else { + parts.join(" ") + }; + format!( + "no selectable inputs for auto-selection: {body} \ + (min_input_amount={min_input_amount}); rotate to a fresh receive address, \ + consolidate funds, or use InputSelection::Explicit" + ) +} + /// Check whether an SDK error indicates that an InstantSend lock proof was /// rejected by Platform (e.g. the IS lock has expired). /// diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 1c0ea40654b..34372cb3ef5 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -307,8 +307,6 @@ impl PlatformWalletInfo { drop(token_balances); // 7. Recompute cached UI balance from the now-restored UTXO set. - // `update_balance` returns its own changeset internally; we - // discard it (apply does not re-emit). use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; self.core_wallet.update_balance(); // Mirror the recomputed balance into the lock-free Arc that the diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs index 8af37949e3b..76a4b5d09cf 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/transfer.rs @@ -18,16 +18,12 @@ impl PlatformAddressWallet { /// Transfer credits between platform addresses. /// /// Input addresses can be specified explicitly or selected automatically - /// from the account via [`InputSelection::Auto`]. - /// - /// If `platform_version` is `None`, the latest platform version's fee - /// schedule is used for fee estimation during auto-selection. + /// from the account via [`InputSelection::Auto`]. When `platform_version` + /// is `None`, [`LATEST_PLATFORM_VERSION`] drives fee estimation. /// /// `address_signer` produces ECDSA signatures for the input - /// [`PlatformAddress`]es. The wallet struct itself carries no key - /// material — callers supply a seed-backed, hardware, or - /// FFI-trampoline signer per their environment (iOS routes through - /// `KeychainSigner` via `VTableSigner`). + /// [`PlatformAddress`]es; the wallet itself holds no key material — + /// callers supply a seed-backed, hardware, or FFI-trampoline signer. pub async fn transfer + Send + Sync>( &self, account_index: u32, @@ -73,6 +69,19 @@ impl PlatformAddressWallet { .await? } InputSelection::Auto => { + // Auto-select supports `[DeductFromInput(0)]` and `[ReduceOutput(0)]`; + // any other shape must use `Explicit`. + if !matches!( + fee_strategy.as_slice(), + [AddressFundsFeeStrategyStep::DeductFromInput(0)] + | [AddressFundsFeeStrategyStep::ReduceOutput(0)] + ) { + return Err(PlatformWalletError::AddressOperation( + "InputSelection::Auto supports fee_strategy = [DeductFromInput(0)] \ + or [ReduceOutput(0)]; for other strategies use InputSelection::Explicit" + .to_string(), + )); + } let inputs = self .auto_select_inputs(account_index, &outputs, &fee_strategy, version) .await?; @@ -82,8 +91,6 @@ impl PlatformAddressWallet { } }; - // Get the cached key source from the unified provider for gap - // limit maintenance. let key_source = { let guard = self.provider.read().await; guard @@ -91,7 +98,6 @@ impl PlatformAddressWallet { .and_then(|p| p.key_source(&self.wallet_id, account_index)) }; - // Update balances in the ManagedPlatformAccount. let mut wm = self.wallet_manager.write().await; let mut cs = PlatformAddressChangeSet::default(); if let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) { @@ -140,9 +146,95 @@ impl PlatformAddressWallet { Ok(cs) } - /// Automatically select input addresses from the account, consuming - /// addresses from lowest derivation index to highest until the total - /// output amount plus estimated fees is covered. + /// Transfer credits with an explicit "change address" override. + /// + /// Companion to [`Self::transfer`] that surfaces the implicit + /// "where does the residual go?" decision as a first-class + /// parameter (PA-001b). + /// + /// Override semantics: + /// - `output_change_address: None` — passthrough to [`Self::transfer`]; + /// residual stays on the input addresses under the existing implicit-change + /// behaviour. + /// - `output_change_address: Some(change_addr)` — every input is spent in + /// full, and `change_addr` is added as an extra output absorbing + /// `Σ inputs − Σ user_outputs`. The protocol's `Σ inputs == Σ outputs` + /// invariant holds because the change output exactly balances the surplus. + /// + /// The change branch requires [`InputSelection::Explicit`] or + /// [`InputSelection::ExplicitWithNonces`]: the caller declares inputs and + /// their consumption explicitly, which is the only shape where "consume + /// the entire input balance" is unambiguous — auto-selection trims to a + /// covering prefix and has no concept of a residual to route. The map's + /// values must equal the full balances the caller wants consumed; the + /// wrapper sums them and assigns the surplus to `change_addr`. + /// + /// # Errors + /// + /// [`PlatformWalletError::AddressOperation`] when the change branch is + /// requested with [`InputSelection::Auto`], when `change_addr` already + /// appears in `user_outputs` (would merge silently), or when + /// `Σ inputs ≤ Σ user_outputs` (no surplus to route). + #[allow(clippy::too_many_arguments)] // mirrors `transfer` plus the change-address override; a builder would obscure PA-001b's additive surface. + pub async fn transfer_with_change_address + Send + Sync>( + &self, + account_index: u32, + input_selection: InputSelection, + user_outputs: BTreeMap, + output_change_address: Option, + fee_strategy: AddressFundsFeeStrategy, + platform_version: Option<&PlatformVersion>, + address_signer: &S, + ) -> Result { + let Some(change_addr) = output_change_address else { + return self + .transfer( + account_index, + input_selection, + user_outputs, + fee_strategy, + platform_version, + address_signer, + ) + .await; + }; + + let (input_sum, augmented_selection) = match input_selection { + InputSelection::Explicit(ref inputs) => ( + inputs.values().copied().sum::(), + InputSelection::Explicit(inputs.clone()), + ), + InputSelection::ExplicitWithNonces(ref inputs) => ( + inputs.values().map(|(_n, c)| *c).sum::(), + InputSelection::ExplicitWithNonces(inputs.clone()), + ), + InputSelection::Auto => { + return Err(PlatformWalletError::AddressOperation( + "output_change_address: Some(_) requires InputSelection::Explicit \ + or ExplicitWithNonces — the auto-selector trims inputs to a covering \ + prefix and has no concept of a residual to route to a change address" + .to_string(), + )); + } + }; + + let outputs_with_change = + augment_outputs_with_change(user_outputs, change_addr, input_sum)?; + + self.transfer( + account_index, + augmented_selection, + outputs_with_change, + fee_strategy, + platform_version, + address_signer, + ) + .await + } + + /// Dispatch to the strategy-specific selector. Returned map values are the + /// **consumed amount per address**; protocol enforces `Σ inputs == Σ outputs`. + /// Supported strategies: `[DeductFromInput(0)]`, `[ReduceOutput(0)]`. async fn auto_select_inputs( &self, account_index: u32, @@ -150,8 +242,12 @@ impl PlatformAddressWallet { fee_strategy: &[AddressFundsFeeStrategyStep], platform_version: &PlatformVersion, ) -> Result, PlatformWalletError> { - let total_output: Credits = outputs.values().sum(); - let output_count = outputs.len(); + // Saturating fold matches the file-wide policy. Total credit supply + // is far below `u64::MAX`, so saturation is unreachable in practice. + let total_output: Credits = outputs + .values() + .copied() + .fold(0u64, Credits::saturating_add); let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -171,58 +267,66 @@ impl PlatformAddressWallet { )) })?; - // BTreeMap iteration is already in ascending index order. - let mut selected = BTreeMap::new(); - let mut accumulated: Credits = 0; + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; - for addr_info in account.addresses.addresses.values() { - if let Ok(p2pkh) = PlatformP2PKHAddress::from_address(&addr_info.address) { + let address_balances: Vec<(PlatformAddress, Credits)> = account + .addresses + .addresses + .values() + .filter_map(|addr_info| { + let p2pkh = PlatformP2PKHAddress::from_address(&addr_info.address).ok()?; let balance = account.address_credit_balance(&p2pkh); - if balance == 0 { - continue; - } - - let address = PlatformAddress::P2pkh(p2pkh.to_bytes()); - selected.insert(address, balance); - accumulated = accumulated.saturating_add(balance); - - // Re-estimate fee with the current input count. - let estimated_fee = Self::estimate_fee_for_inputs( - selected.len(), - output_count, - fee_strategy, - outputs, - platform_version, - ); - let required = total_output.saturating_add(estimated_fee); + Some((PlatformAddress::P2pkh(p2pkh.to_bytes()), balance)) + }) + .collect(); + let candidates = build_auto_select_candidates( + address_balances.iter().copied(), + outputs, + min_input_amount, + ); - if accumulated >= required { - return Ok(selected); - } + // Empty candidates: classify the cause and raise a typed diagnostic + // so callers don't parse downstream message strings. + if candidates.is_empty() { + if let Some(err) = detect_no_selectable_inputs( + address_balances.iter().copied(), + outputs, + min_input_amount, + ) { + return Err(err); } } - // Not enough funds. - let estimated_fee = Self::estimate_fee_for_inputs( - selected.len().max(1), - output_count, - fee_strategy, - outputs, - platform_version, - ); - let required = total_output.saturating_add(estimated_fee); - Err(PlatformWalletError::AddressOperation(format!( - "Insufficient balance: available {} credits, required {} (outputs {} + estimated fee {})", - accumulated, required, total_output, estimated_fee - ))) + match fee_strategy { + [AddressFundsFeeStrategyStep::DeductFromInput(0)] => select_inputs_deduct_from_input( + candidates, + outputs, + total_output, + fee_strategy, + platform_version, + ), + [AddressFundsFeeStrategyStep::ReduceOutput(0)] => select_inputs_reduce_output( + candidates, + outputs, + total_output, + fee_strategy, + platform_version, + ), + _ => Err(PlatformWalletError::AddressOperation( + "auto_select_inputs supports fee_strategy = [DeductFromInput(0)] \ + or [ReduceOutput(0)]; other shapes must use InputSelection::Explicit" + .to_string(), + )), + } } /// Simulate the fee strategy to determine how much additional balance - /// the inputs need beyond the output amounts. - /// - /// Walks through the fee strategy steps in order, deducting from the - /// available sources (outputs or inputs) until the fee is covered. - /// Returns the portion of the fee that must come from inputs. + /// the inputs need beyond the output amounts. Walks the strategy steps + /// in order and returns the residual fee inputs must cover. fn estimate_fee_for_inputs( input_count: usize, output_count: usize, @@ -245,24 +349,939 @@ impl PlatformAddressWallet { } match step { AddressFundsFeeStrategyStep::ReduceOutput(index) => { - // This output absorbs part of the fee — inputs don't need to cover it. if let Some(&amount) = output_amounts.get(*index as usize) { let reduction = remaining_fee.min(amount); remaining_fee -= reduction; } } AddressFundsFeeStrategyStep::DeductFromInput(_) => { - // Inputs will cover whatever fee remains at this step. - // We don't reduce remaining_fee here because we're - // computing the total that inputs must cover — this - // step confirms inputs pay, but the actual deduction - // happens on-chain from whichever input is specified. break; } } } - // Whatever fee wasn't covered by reducing outputs must come from inputs. remaining_fee } } + +/// Build the auto-selection candidate list: keep only addresses whose balance +/// reaches `min_input_amount`, drop any address that is also a destination +/// output (the protocol forbids the same address being both input and output), +/// then sort balance-descending so the selector picks the smallest covering +/// prefix. +fn build_auto_select_candidates( + address_balances: I, + outputs: &BTreeMap, + min_input_amount: Credits, +) -> Vec<(PlatformAddress, Credits)> +where + I: IntoIterator, +{ + let mut candidates: Vec<(PlatformAddress, Credits)> = address_balances + .into_iter() + .filter(|(addr, balance)| *balance >= min_input_amount && !outputs.contains_key(addr)) + .collect(); + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + candidates +} + +/// Classify why no candidate survived the filter. Returns `None` when no +/// funded address exists at all (caller falls through to generic +/// insufficient-balance); otherwise reports both failure shapes in one variant. +fn detect_no_selectable_inputs( + address_balances: I, + outputs: &BTreeMap, + min_input_amount: Credits, +) -> Option +where + I: IntoIterator, +{ + let mut funded_outputs: Vec = Vec::new(); + let mut sub_min_count: usize = 0; + let mut sub_min_aggregate: Credits = 0; + for (addr, balance) in address_balances { + if balance >= min_input_amount { + if outputs.contains_key(&addr) { + funded_outputs.push(addr); + } + } else if balance > 0 { + sub_min_count = sub_min_count.saturating_add(1); + sub_min_aggregate = sub_min_aggregate.saturating_add(balance); + } + } + if funded_outputs.is_empty() && sub_min_count == 0 { + return None; + } + Some(PlatformWalletError::NoSelectableInputs { + funded_outputs, + sub_min_count, + sub_min_aggregate, + min_input_amount, + }) +} + +/// `[DeductFromInput(0)]` selector. Order-agnostic: walks `candidates` as-is +/// and picks the smallest covering prefix. +/// +/// Produces an inputs map satisfying: +/// 1. `Σ selected.values() == total_output`. +/// 2. The `DeductFromInput(0)` fee target — the lex-smallest entry, which is +/// the `BTreeMap` index-0 — must keep `balance − consumed ≥ estimated_fee` +/// so drive can deduct the fee from its remaining balance (otherwise +/// `fee_fully_covered = false` and the transition is rejected). +/// +/// Algorithm: +/// 1. Grow the prefix until `Σ balances ≥ total_output + estimated_fee`. +/// 2. Within that prefix, the lex-smallest entry is the fee target. +/// 3. Solve for `fee_target_consumed` in +/// `[max(min_input_amount, total_output − other_total), +/// fee_target_balance − estimated_fee]`. If the range is empty, extend +/// the prefix and retry; error out only when candidates are exhausted. +/// 4. Insert the fee target at its minimum consumption, then distribute the +/// remainder of `total_output` across the other prefix entries. Tail +/// consumptions below `min_input_amount` get folded back into the fee +/// target rather than producing a sub-minimum input. +/// 5. Defensive invariant checks. +fn select_inputs_deduct_from_input( + candidates: Vec<(PlatformAddress, Credits)>, + outputs: &BTreeMap, + total_output: Credits, + fee_strategy: &[AddressFundsFeeStrategyStep], + platform_version: &PlatformVersion, +) -> Result, PlatformWalletError> { + if !matches!( + fee_strategy, + [AddressFundsFeeStrategyStep::DeductFromInput(0)] + ) { + return Err(PlatformWalletError::AddressOperation( + "select_inputs_deduct_from_input only supports fee_strategy = \ + [DeductFromInput(0)]; other shapes must route through the dispatcher" + .to_string(), + )); + } + + let output_count = outputs.len(); + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + + // No input can simultaneously be ≥ `min_input_amount` AND sum to + // `total_output` if `total_output < min_input_amount`. Reject upfront. + if total_output < min_input_amount { + return Err(PlatformWalletError::AddressOperation(format!( + "Transfer amount {} is below the protocol minimum input amount {}; \ + a transfer cannot be split across inputs in a way that satisfies \ + the per-input minimum", + total_output, min_input_amount, + ))); + } + + // Saturating arithmetic on `Credits` (== u64): total Dash credit supply + // is far below `u64::MAX`, so saturation is unreachable in practice. + let mut prefix: Vec<(PlatformAddress, Credits)> = Vec::new(); + let mut accumulated: Credits = 0; + let mut last_estimated_fee: Credits = 0; + let mut feasible: Option<(PlatformAddress, Credits, Credits, Credits)> = None; + + for (address, balance) in candidates { + prefix.push((address, balance)); + accumulated = accumulated.saturating_add(balance); + + let estimated_fee = PlatformAddressWallet::estimate_fee_for_inputs( + prefix.len(), + output_count, + fee_strategy, + outputs, + platform_version, + ); + last_estimated_fee = estimated_fee; + let required = total_output.saturating_add(estimated_fee); + + if accumulated < required { + continue; + } + + let (fee_target_addr, fee_target_balance) = prefix + .iter() + .min_by_key(|(addr, _)| *addr) + .copied() + .expect("prefix is non-empty: we just pushed"); + + let fee_target_max = fee_target_balance.saturating_sub(estimated_fee); + let other_total: Credits = prefix + .iter() + .filter(|(addr, _)| addr != &fee_target_addr) + .map(|(_, bal)| *bal) + .sum(); + let fee_target_min = + std::cmp::max(min_input_amount, total_output.saturating_sub(other_total)); + + if fee_target_min <= fee_target_max { + feasible = Some(( + fee_target_addr, + fee_target_balance, + fee_target_min, + estimated_fee, + )); + break; + } + } + + let Some((fee_target_addr, fee_target_balance, fee_target_min, estimated_fee)) = feasible + else { + let required_total = total_output.saturating_add(last_estimated_fee); + if accumulated < required_total { + return Err(PlatformWalletError::AddressOperation(format!( + "Insufficient balance: available {} credits, required {} \ + (outputs {} + estimated fee {}; [DeductFromInput(0)])", + accumulated, required_total, total_output, last_estimated_fee, + ))); + } + return Err(PlatformWalletError::AddressOperation(format!( + "Cannot satisfy fee headroom: no covering prefix of the available inputs \ + leaves the lex-smallest entry with ≥ estimated fee {} of remaining balance \ + after consumption. Consider providing more inputs or using a different \ + fee strategy.", + last_estimated_fee, + ))); + }; + + // Sub-minimum tail consumptions fold back into the fee target; + // `validate_structure` would otherwise reject `InputBelowMinimumError`. + let mut fee_target_consumed = fee_target_min; + let fee_target_max = fee_target_balance.saturating_sub(estimated_fee); + let mut selected: BTreeMap = BTreeMap::new(); + + let mut remaining = total_output.saturating_sub(fee_target_consumed); + let mut residue_to_fee_target: Credits = 0; + for (addr, bal) in prefix.iter() { + if *addr == fee_target_addr { + continue; + } + if remaining == 0 { + break; + } + let tentative = (*bal).min(remaining); + if tentative == 0 { + continue; + } + if tentative < min_input_amount { + residue_to_fee_target = residue_to_fee_target.saturating_add(tentative); + remaining = remaining.saturating_sub(tentative); + continue; + } + selected.insert(*addr, tentative); + remaining = remaining.saturating_sub(tentative); + } + + if residue_to_fee_target > 0 { + let new_consumed = fee_target_consumed.saturating_add(residue_to_fee_target); + // Unreachable given Phase 3's headroom check; debug_assert surfaces a + // logic regression in test runs, the runtime Err keeps release safe. + debug_assert!( + new_consumed <= fee_target_max, + "fee target consumption {} exceeds max {} after residue fold", + new_consumed, + fee_target_max, + ); + if new_consumed > fee_target_max { + return Err(PlatformWalletError::AddressOperation(format!( + "Cannot satisfy fee headroom after redistributing sub-minimum tail \ + inputs: fee-target {} would consume {} (balance {}, max {}), leaving \ + less than estimated fee {} of remaining balance", + format_address(&fee_target_addr), + new_consumed, + fee_target_balance, + fee_target_max, + estimated_fee, + ))); + } + fee_target_consumed = new_consumed; + } + + selected.insert(fee_target_addr, fee_target_consumed); + + // Defensive post-checks: a malformed Σ or misaligned fee target would ship + // a guaranteed-rejected transition. debug_assert surfaces the regression in + // test runs; runtime Err keeps release builds safe. + debug_assert_eq!( + selected.values().copied().sum::(), + total_output, + "Σ inputs must equal Σ outputs" + ); + debug_assert_eq!( + selected.keys().next().copied(), + Some(fee_target_addr), + "fee target must be the BTreeMap index-0 (lex-smallest) entry", + ); + if selected.keys().next().copied() != Some(fee_target_addr) { + return Err(PlatformWalletError::AddressOperation(format!( + "Internal selection error: fee target {} is not the BTreeMap index-0 \ + (lex-smallest) entry; first entry is {:?}", + format_address(&fee_target_addr), + selected.keys().next().map(format_address), + ))); + } + debug_assert!( + fee_target_balance.saturating_sub(fee_target_consumed) >= estimated_fee, + "fee target must retain ≥ estimated_fee for DeductFromInput(0)", + ); + if fee_target_balance.saturating_sub(fee_target_consumed) < estimated_fee { + return Err(PlatformWalletError::AddressOperation(format!( + "Internal selection error: fee target {} retains {} after consumption, \ + below estimated fee {}", + format_address(&fee_target_addr), + fee_target_balance.saturating_sub(fee_target_consumed), + estimated_fee, + ))); + } + + Ok(selected) +} + +/// `[ReduceOutput(0)]` selector. Output 0 absorbs the fee at chain time, so +/// inputs only need to sum to `total_output` — no fee headroom on inputs. +/// +/// Algorithm: +/// 1. Grow the prefix until `Σ balances ≥ total_output`. +/// 2. Trim the last prefix entry by `surplus = Σ − total_output` so +/// `Σ inputs == Σ outputs`. Earlier entries stay at full balance. +/// 3. If the trim drops the last entry below `min_input_amount`, shift +/// consumption from a peer in **balance-descending donor order** (largest +/// peer first) to lift it back up while keeping the donor ≥ +/// `min_input_amount`. Error out if no peer has the headroom. +/// 4. Estimate the fee for the chosen input count and verify +/// `output[0] ≥ estimated_fee`; otherwise the chain-time deduction would +/// leave the fee uncovered. +/// 5. Defensive invariant checks. +fn select_inputs_reduce_output( + candidates: Vec<(PlatformAddress, Credits)>, + outputs: &BTreeMap, + total_output: Credits, + fee_strategy: &[AddressFundsFeeStrategyStep], + platform_version: &PlatformVersion, +) -> Result, PlatformWalletError> { + if !matches!(fee_strategy, [AddressFundsFeeStrategyStep::ReduceOutput(0)]) { + return Err(PlatformWalletError::AddressOperation( + "select_inputs_reduce_output only supports fee_strategy = \ + [ReduceOutput(0)]; other shapes must route through the dispatcher" + .to_string(), + )); + } + + let output_count = outputs.len(); + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + + if total_output < min_input_amount { + return Err(PlatformWalletError::AddressOperation(format!( + "Transfer amount {} is below the protocol minimum input amount {}; \ + a transfer cannot be split across inputs in a way that satisfies \ + the per-input minimum", + total_output, min_input_amount, + ))); + } + + // Saturating arithmetic everywhere: total credit supply is far below + // `u64::MAX`, so saturation is unreachable in practice. + let mut prefix: Vec<(PlatformAddress, Credits)> = Vec::new(); + let mut accumulated: Credits = 0; + for (address, balance) in candidates { + prefix.push((address, balance)); + accumulated = accumulated.saturating_add(balance); + if accumulated >= total_output { + break; + } + } + + if accumulated < total_output { + return Err(PlatformWalletError::AddressOperation(format!( + "Insufficient balance: available {} credits, required {} \ + (outputs sum; [ReduceOutput(0)] absorbs the fee from output 0)", + accumulated, total_output, + ))); + } + + // Module-internal guard for direct test/future-caller invocations; + // production callers pre-filter via `build_auto_select_candidates`. + if let Some((bad_addr, bad_balance)) = prefix + .iter() + .find(|(_, balance)| *balance < min_input_amount) + { + return Err(PlatformWalletError::AddressOperation(format!( + "Candidate {} has balance {} below min_input_amount {}; \ + callers must pre-filter via build_auto_select_candidates \ + before invoking the selector", + format_address(bad_addr), + bad_balance, + min_input_amount, + ))); + } + + // Phase 2: every prefix entry consumes its full balance except the last, + // which absorbs the surplus. + let mut selected: BTreeMap = BTreeMap::new(); + let surplus = accumulated - total_output; + let last_index = prefix.len() - 1; + for (i, (addr, balance)) in prefix.iter().enumerate() { + let consumed = if i == last_index { + balance.saturating_sub(surplus) + } else { + *balance + }; + selected.insert(*addr, consumed); + } + + // Donor must keep ≥ `min_input_amount` itself, so its balance must reach + // `min_input_amount + shift`. Largest-peer-first maximises that chance. + let last_addr = prefix[last_index].0; + let last_consumed = selected[&last_addr]; + if last_consumed < min_input_amount && prefix.len() > 1 { + let shift = min_input_amount - last_consumed; + let donor_threshold = min_input_amount.saturating_add(shift); + let mut donor_candidates: Vec<&(PlatformAddress, Credits)> = prefix + .iter() + .filter(|(addr, _)| *addr != last_addr) + .collect(); + donor_candidates.sort_by(|a, b| b.1.cmp(&a.1)); + let donor_addr = donor_candidates + .into_iter() + .find(|(_, balance)| *balance >= donor_threshold) + .map(|(addr, _)| *addr); + let Some(donor_addr) = donor_addr else { + return Err(PlatformWalletError::AddressOperation(format!( + "Cannot satisfy per-input minimum: trimming the last input to \ + {} (below {}) and no peer has ≥ {} of headroom to redistribute", + last_consumed, min_input_amount, donor_threshold, + ))); + }; + let donor_consumed = selected[&donor_addr]; + selected.insert(donor_addr, donor_consumed.saturating_sub(shift)); + selected.insert(last_addr, last_consumed.saturating_add(shift)); + } + + // TODO(platform#3040): replace with chain-time fee API. Static estimate + // can be ~2.3x below chain-time, leaving small `output[0]` at risk. + let estimated_fee = PlatformAddressWallet::estimate_fee_for_inputs( + selected.len(), + output_count, + fee_strategy, + outputs, + platform_version, + ); + let output_0 = outputs.values().next().copied().unwrap_or(0); + if output_0 < estimated_fee { + return Err(PlatformWalletError::AddressOperation(format!( + "Output 0 ({} credits) cannot absorb estimated fee ({} credits) \ + under [ReduceOutput(0)]; raise output 0 or use a different fee strategy", + output_0, estimated_fee, + ))); + } + + // TODO(platform#3040): drop the heuristic 3x safety band once chain-time + // fee API lands; current ~2.3x observed gap is not a proven boundary. + const REDUCE_OUTPUT_FEE_SAFETY_MULTIPLE: Credits = 3; + let safe_threshold = estimated_fee.saturating_mul(REDUCE_OUTPUT_FEE_SAFETY_MULTIPLE); + if output_0 < safe_threshold { + tracing::warn!( + output_0, + estimated_fee, + safety_multiple = REDUCE_OUTPUT_FEE_SAFETY_MULTIPLE, + tracking_issue = "platform#3040", + "[ReduceOutput(0)] output 0 ({} credits) is within {}x of the static estimated \ + fee ({} credits); chain-time fee may exceed the static estimate (platform#3040), \ + risking on-chain rejection. Consider raising output 0 or switching to \ + [DeductFromInput(0)].", + output_0, + REDUCE_OUTPUT_FEE_SAFETY_MULTIPLE, + estimated_fee, + ); + } + + debug_assert_eq!( + selected.values().copied().sum::(), + total_output, + "Σ inputs must equal Σ outputs" + ); + + Ok(selected) +} + +/// Augment `user_outputs` with an explicit change output absorbing the +/// surplus `Σ inputs − Σ user_outputs`. Validates the three error cases +/// `transfer_with_change_address` rejects before dispatching to `transfer`: +/// duplicate change address, no surplus, and (defensively) underflow. +fn augment_outputs_with_change( + mut user_outputs: BTreeMap, + change_addr: PlatformAddress, + input_sum: Credits, +) -> Result, PlatformWalletError> { + if user_outputs.contains_key(&change_addr) { + return Err(PlatformWalletError::AddressOperation(format!( + "output_change_address {change_addr:?} already appears in user_outputs; \ + refusing to silently merge a change-output amount into a caller-declared \ + output. Pick a fresh change_addr.", + ))); + } + let user_output_sum: Credits = user_outputs.values().copied().sum(); + if input_sum <= user_output_sum { + return Err(PlatformWalletError::AddressOperation(format!( + "output_change_address: Some(_) requires Σ inputs ({input_sum}) > \ + Σ user_outputs ({user_output_sum}); no surplus to route as change. \ + Drop output_change_address or grow the input map.", + ))); + } + // Saturating arithmetic mirrors the file-wide policy; total credit supply + // is far below `u64::MAX`, and the guard above already rules out underflow. + let change_amount = input_sum.saturating_sub(user_output_sum); + user_outputs.insert(change_addr, change_amount); + Ok(user_outputs) +} + +fn format_address(addr: &PlatformAddress) -> String { + match addr { + PlatformAddress::P2pkh(hash) => format!("p2pkh({})", hex::encode(hash)), + PlatformAddress::P2sh(hash) => format!("p2sh({})", hex::encode(hash)), + } +} + +#[cfg(test)] +mod auto_select_tests { + use super::*; + use dpp::address_funds::AddressWitness; + use dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; + use dpp::state_transition::StateTransitionStructureValidation; + + fn p2pkh(byte: u8) -> PlatformAddress { + PlatformAddress::P2pkh([byte; 20]) + } + + fn outputs_for(target: PlatformAddress, amount: Credits) -> BTreeMap { + std::iter::once((target, amount)).collect() + } + + /// Feed a selector result into dpp's `validate_structure` to confirm the + /// transition is shape-valid. Uses zero nonces and dummy P2PKH witnesses. + fn assert_selection_validates( + selected: &BTreeMap, + outputs: &BTreeMap, + fee_strategy: Vec, + platform_version: &PlatformVersion, + ) { + let inputs = selected + .iter() + .map(|(addr, amount)| (*addr, (0u32, *amount))) + .collect(); + let input_witnesses = (0..selected.len()) + .map(|_| AddressWitness::P2pkh { + signature: vec![0u8; 65].into(), + }) + .collect(); + let transition = AddressFundsTransferTransitionV0 { + inputs, + outputs: outputs.clone(), + fee_strategy, + user_fee_increase: 0, + input_witnesses, + }; + let result = transition.validate_structure(platform_version); + assert!( + result.is_valid(), + "validate_structure rejected the selection: {:?}", + result.errors, + ); + } + + /// One address with a large balance, output amount well below it → + /// `selected[addr] == total_output` (NOT full balance, NOT `total_output + fee`). + /// Fee comes from the address's remaining balance via `DeductFromInput(0)`. + #[test] + fn single_input_oversized_balance_trims_to_output_amount() { + let addr = p2pkh(0x11); + let target = p2pkh(0x22); + let outputs = outputs_for(target, 10_000_000); + let total_output = 10_000_000u64; + let candidates = vec![(addr, 100_000_000u64)]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let pv = LATEST_PLATFORM_VERSION; + + let selected = + select_inputs_deduct_from_input(candidates, &outputs, total_output, &fee_strategy, pv) + .expect("selection"); + + assert_eq!(selected.get(&addr), Some(&10_000_000)); + let input_sum: Credits = selected.values().sum(); + assert_eq!(input_sum, outputs.values().copied().sum::()); + + assert_selection_validates(&selected, &outputs, fee_strategy, pv); + } + + /// Balance-descending input — the order `auto_select_inputs` supplies — + /// with a single largest balance covering `total_output + fee` produces a + /// 1-input map. + #[test] + fn descending_order_picks_single_largest_when_sufficient() { + let addr_small = p2pkh(0x01); + let addr_large = p2pkh(0xFE); + let target = p2pkh(0xCC); + let total_output = 30_000_000u64; + let outputs = outputs_for(target, total_output); + let candidates = vec![(addr_large, 100_000_000), (addr_small, 5_000_000)]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let pv = LATEST_PLATFORM_VERSION; + + let selected = + select_inputs_deduct_from_input(candidates, &outputs, total_output, &fee_strategy, pv) + .expect("selection"); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[&addr_large], total_output); + + assert_selection_validates(&selected, &outputs, fee_strategy, pv); + } + + /// Protocol-level proof: the inputs map a naive selector would produce + /// for `(20M, 50M)` / `total_output = 30M` / `[DeductFromInput(0)]` + /// (`{addr_a: 20M, addr_b: 10M}`), when fed to + /// `deduct_fee_from_outputs_or_remaining_balance_of_inputs`, returns + /// `fee_fully_covered = false` — drive's `validate_fees_of_event` would + /// reject the transition. The fixed selector retains `min_input_amount` + /// at addr_a so the fee deduction has headroom. + #[test] + fn pre_fix_buggy_selector_output_is_rejected_by_protocol_fee_deduction() { + use dpp::address_funds::fee_strategy::deduct_fee_from_inputs_and_outputs::deduct_fee_from_outputs_or_remaining_balance_of_inputs; + use dpp::prelude::AddressNonce; + + let addr_a = p2pkh(0x01); + let addr_b = p2pkh(0x02); + let target = p2pkh(0xFF); + let total_output = 30_000_000u64; + let addr_a_balance = 20_000_000u64; + let addr_b_balance = 50_000_000u64; + let outputs = outputs_for(target, total_output); + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let pv = LATEST_PLATFORM_VERSION; + + let mut buggy_inputs_consumed: BTreeMap = BTreeMap::new(); + buggy_inputs_consumed.insert(addr_a, 20_000_000); + buggy_inputs_consumed.insert(addr_b, 10_000_000); + + let mut input_current_balances: BTreeMap = + BTreeMap::new(); + input_current_balances.insert(addr_a, (0, addr_a_balance - 20_000_000)); + input_current_balances.insert(addr_b, (0, addr_b_balance - 10_000_000)); + + let fee: Credits = 1_000_000; + let added_to_outputs: BTreeMap = outputs.clone(); + + let result = deduct_fee_from_outputs_or_remaining_balance_of_inputs( + input_current_balances.clone(), + added_to_outputs, + &fee_strategy, + fee, + pv, + ) + .expect("deduction call must succeed (rejection is via fee_fully_covered)"); + + assert!( + !result.fee_fully_covered, + "Pre-fix selector's output must be rejected by the protocol's fee deduction" + ); + assert!(addr_b_balance - 10_000_000 >= fee); + + // Cross-check: the fixed selector at the same fixture produces a + // map that DOES leave headroom on addr_a. + let fixed = select_inputs_deduct_from_input( + vec![(addr_a, addr_a_balance), (addr_b, addr_b_balance)], + &outputs, + total_output, + &fee_strategy, + pv, + ) + .expect("fixed selector"); + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + assert_eq!(fixed.get(&addr_a), Some(&min_input)); + assert_eq!(fixed.keys().next(), Some(&addr_a)); + assert_selection_validates(&fixed, &outputs, fee_strategy, pv); + } + + /// Phase 1 covers `total_output + fee` but the lex-smallest entry has no + /// headroom for the fee. Selection must error out rather than ship a + /// transition the validator will reject. + #[test] + fn fee_headroom_violation_errors() { + let addr_a = p2pkh(0x01); + let addr_b = p2pkh(0x02); + let target = p2pkh(0x99); + let pv = LATEST_PLATFORM_VERSION; + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + // addr_a holds exactly `min_input_amount` — no remaining balance for + // the fee. addr_b lets Phase 1 succeed, so the headroom violation + // must be caught in Phase 3. + let addr_a_balance = min_input; + let total_output = 10_000_000u64; + let addr_b_balance = 20_000_000u64; + let outputs = outputs_for(target, total_output); + let candidates = vec![(addr_a, addr_a_balance), (addr_b, addr_b_balance)]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let err = + select_inputs_deduct_from_input(candidates, &outputs, total_output, &fee_strategy, pv) + .expect_err("expected fee-headroom error"); + match err { + PlatformWalletError::AddressOperation(msg) => { + assert!(msg.contains("Cannot satisfy fee headroom"), "got {msg:?}"); + } + other => panic!("expected AddressOperation, got {other:?}"), + } + } + + /// Tail entry's tentative consumption falls below `min_input_amount`. The + /// selector folds the residue back into the fee target so every shipped + /// input ≥ `min_input_amount`. + #[test] + fn non_fee_target_below_min_input_redistributes() { + let addr_x = p2pkh(0x01); // lex-smallest → fee target + let addr_y = p2pkh(0x02); // sub-min peer; folds into fee target + let addr_z = p2pkh(0x03); // large peer; absorbs the bulk + let target = p2pkh(0x99); + let pv = LATEST_PLATFORM_VERSION; + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + // Fixture (numbers chosen against fee schedule `500_000*N + 6_000_000`): + // - prefix [x] (acc 10M) doesn't cover 10.5M (=4M+fee_1in). + // - prefix [x,y] (acc 10.08M) doesn't cover 11M (=4M+fee_2in). + // - prefix [x,y,z] (acc 12.08M) covers 11.5M. + // - Phase 4: y's tentative=80k folds into fee target; z absorbs 2M. + let total_output = 4_000_000u64; + let addr_x_balance = 10_000_000u64; + let addr_y_balance = 80_000u64; // below min_input_amount (100_000) + let addr_z_balance = 2_000_000u64; + let outputs = outputs_for(target, total_output); + let candidates = vec![ + (addr_x, addr_x_balance), + (addr_y, addr_y_balance), + (addr_z, addr_z_balance), + ]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let selected = + select_inputs_deduct_from_input(candidates, &outputs, total_output, &fee_strategy, pv) + .expect("redistribute path must reach Ok"); + + for (addr, amount) in selected.iter() { + assert!( + *amount >= min_input, + "{} consumes {amount}", + format_address(addr) + ); + } + assert!( + !selected.contains_key(&addr_y), + "sub-min y must be folded out" + ); + let input_sum: Credits = selected.values().sum(); + assert_eq!(input_sum, total_output); + + assert_selection_validates(&selected, &outputs, fee_strategy, pv); + } + + /// QA-001: an address that is also a destination output must be excluded + /// from auto-selection candidates, even when it is the only address with + /// sufficient balance. Otherwise the protocol would reject the transition + /// with `Output address cannot also be an input address`. + #[test] + fn auto_select_inputs_excludes_output_addresses() { + let pv = LATEST_PLATFORM_VERSION; + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + let addr_a = p2pkh(0xA1); + let addr_b = p2pkh(0xB2); + let outputs = outputs_for(addr_a, min_input); + + let address_balances = vec![(addr_a, min_input * 3), (addr_b, min_input / 2)]; + let candidates = + build_auto_select_candidates(address_balances.clone(), &outputs, min_input); + assert!(candidates.is_empty(), "got {candidates:?}"); + + let no_outputs = BTreeMap::new(); + let with_self_spend = + build_auto_select_candidates(address_balances, &no_outputs, min_input); + assert_eq!(with_self_spend, vec![(addr_a, min_input * 3)]); + } + + /// Empty candidate list → error rather than panic / silent zero-input transition. + #[test] + fn no_candidates_errors() { + let target = p2pkh(0x55); + let outputs = outputs_for(target, 1_000_000); + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + let pv = LATEST_PLATFORM_VERSION; + + let err = + select_inputs_deduct_from_input(Vec::new(), &outputs, 1_000_000, &fee_strategy, pv) + .expect_err("expected error for empty candidates"); + assert!(matches!(err, PlatformWalletError::AddressOperation(_))); + } + + /// `total_output < min_input_amount` is unsatisfiable. The selector must + /// reject upfront with a descriptive error. + #[test] + fn total_output_below_min_input_amount_errors() { + let addr = p2pkh(0x10); + let target = p2pkh(0x90); + let pv = LATEST_PLATFORM_VERSION; + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + let total_output = min_input - 1; + let outputs = outputs_for(target, total_output); + let candidates = vec![(addr, 100_000_000)]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let err = + select_inputs_deduct_from_input(candidates, &outputs, total_output, &fee_strategy, pv) + .expect_err("expected below-min-input error"); + match err { + PlatformWalletError::AddressOperation(msg) => { + assert!( + msg.contains("below the protocol minimum input amount"), + "{msg:?}" + ); + } + other => panic!("expected AddressOperation, got {other:?}"), + } + } + + /// Single input fully covers `total_output`; the input is trimmed to + /// `total_output` (no fee headroom on inputs — output 0 absorbs the fee + /// at chain time). + #[test] + fn reduce_output_happy_path_single_input() { + let addr = p2pkh(0x11); + let target = p2pkh(0x22); + let total_output = 10_000_000u64; + let outputs = outputs_for(target, total_output); + let candidates = vec![(addr, 100_000_000u64)]; + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + let pv = LATEST_PLATFORM_VERSION; + + let selected = + select_inputs_reduce_output(candidates, &outputs, total_output, &fee_strategy, pv) + .expect("selection"); + + assert_eq!(selected.get(&addr), Some(&total_output)); + let input_sum: Credits = selected.values().sum(); + assert_eq!(input_sum, total_output); + + assert_selection_validates(&selected, &outputs, fee_strategy, pv); + } + + /// Both failure modes coexist: one funded-but-also-output address AND + /// one sub-min address. Detector reports both via the unified + /// `NoSelectableInputs` variant. + #[test] + fn detect_no_selectable_inputs_combines_both_cases() { + let pv = LATEST_PLATFORM_VERSION; + let min_input = pv.dpp.state_transitions.address_funds.min_input_amount; + + let addr_out = p2pkh(0xC3); + let addr_dust = p2pkh(0xD4); + let outputs = outputs_for(addr_out, min_input); + let address_balances = [(addr_out, min_input * 5), (addr_dust, min_input / 3)]; + + let err = + detect_no_selectable_inputs(address_balances.iter().copied(), &outputs, min_input) + .expect("expected NoSelectableInputs"); + match &err { + PlatformWalletError::NoSelectableInputs { + funded_outputs, + sub_min_count, + sub_min_aggregate, + min_input_amount, + } => { + assert_eq!(funded_outputs, &vec![addr_out]); + assert_eq!(*sub_min_count, 1); + assert_eq!(*sub_min_aggregate, min_input / 3); + assert_eq!(*min_input_amount, min_input); + } + other => panic!("expected NoSelectableInputs, got {other:?}"), + } + let rendered = err.to_string(); + assert!(rendered.contains("funded_outputs"), "{rendered}"); + assert!(rendered.contains("sub_min_count"), "{rendered}"); + + // No funded address at all → detector returns None (caller falls + // through to generic insufficient-balance error). + let no_funds = [(addr_out, 0u64), (addr_dust, 0u64)]; + assert!( + detect_no_selectable_inputs(no_funds.iter().copied(), &outputs, min_input).is_none() + ); + } + + /// PA-001b: the change-address override must add exactly one extra output + /// absorbing `Σ inputs − Σ user_outputs`, leaving `Σ inputs == Σ outputs` + /// so the protocol's structural invariant still holds. + #[test] + fn augment_outputs_with_change_adds_residual_output() { + let user_target = p2pkh(0x22); + let change_addr = p2pkh(0x33); + let user_outputs = outputs_for(user_target, 5_000_000); + let outputs = + augment_outputs_with_change(user_outputs, change_addr, 60_000_000).expect("augment"); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs.get(&user_target), Some(&5_000_000)); + assert_eq!( + outputs.get(&change_addr), + Some(&55_000_000), + "change output must absorb exactly the surplus" + ); + let output_sum: Credits = outputs.values().sum(); + assert_eq!( + output_sum, 60_000_000, + "Σ outputs must equal input sum (Σ inputs == Σ outputs invariant)" + ); + } + + /// PA-001b: the override must reject a `change_addr` that already appears + /// in the caller's user outputs to prevent a silent merge. + #[test] + fn augment_outputs_with_change_rejects_duplicate_address() { + let target = p2pkh(0x44); + let user_outputs = outputs_for(target, 5_000_000); + let err = augment_outputs_with_change(user_outputs, target, 60_000_000) + .expect_err("change_addr equal to user output must be rejected"); + match err { + PlatformWalletError::AddressOperation(msg) => { + assert!( + msg.contains("already appears in user_outputs"), + "unexpected message: {msg}" + ); + } + other => panic!("expected AddressOperation, got {other:?}"), + } + } + + /// PA-001b: when `Σ user_outputs ≥ Σ inputs` there is no surplus to route. + /// The wrapper must reject rather than emit a zero-credit (or underflowing) + /// change output. + #[test] + fn augment_outputs_with_change_rejects_no_surplus() { + let target = p2pkh(0x55); + let change_addr = p2pkh(0x66); + let user_outputs = outputs_for(target, 60_000_000); + let err = augment_outputs_with_change(user_outputs, change_addr, 60_000_000) + .expect_err("equal sums must be rejected: nothing to route as change"); + match err { + PlatformWalletError::AddressOperation(msg) => { + assert!(msg.contains("no surplus"), "unexpected message: {msg}"); + } + other => panic!("expected AddressOperation, got {other:?}"), + } + } +}