From d6456cb601ca5c2c9009a531e6c91415c9fe1e01 Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Wed, 12 Aug 2026 16:55:48 -0700 Subject: [PATCH 1/2] Retry dusty CAT combines onto a remainder coin instead of lowering the dust floor. Spread-adjusted buy clips left sub-CAT change on the tightest covering set, so the shaper returned CannotFund even when another coin could absorb legal change. --- docs/README.md | 1 + .../0026-combine-dust-remainder-coin.md | 34 ++++ docs/progress.md | 6 + greenfloor-engine/src/coin_ops/policy.rs | 18 ++ greenfloor-engine/src/coin_ops/selection.rs | 91 +++++++--- .../src/coin_ops/shape/combine.rs | 159 ++++++++++++++---- 6 files changed, 247 insertions(+), 62 deletions(-) create mode 100644 docs/decisions/0026-combine-dust-remainder-coin.md diff --git a/docs/README.md b/docs/README.md index 8ee99495..de893df3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ accepted decision** when onboarding. | ADR | Topic | | ---------------------------------------------------------------- | ----------------------------------------------------------------------- | +| [0026](decisions/0026-combine-dust-remainder-coin.md) | **Combine dust remainder** — extra input absorbs CAT change; keep 1 CAT | | [0025](decisions/0025-two-sided-target-spread.md) | **Two-sided spread** — bid/ask around mid; sell-only omits the field | | [0023](decisions/0023-canonical-cat-outer-puzzle-hash.md) | **CAT outer hash** — one `coinset/cats/outer` primitive + Coinset hex | | [0021](decisions/0021-three-ownership-simplifications.md) | **Ownership spines** — expired maker, reconcile prep, `coin_ops::shape` | diff --git a/docs/decisions/0026-combine-dust-remainder-coin.md b/docs/decisions/0026-combine-dust-remainder-coin.md new file mode 100644 index 00000000..155ec569 --- /dev/null +++ b/docs/decisions/0026-combine-dust-remainder-coin.md @@ -0,0 +1,34 @@ +# ADR 0026: Combine dusty overshoot takes a remainder coin + +## Status + +Accepted (2026-08-12). + +## Context + +Two-sided spread (ADR 0025) moved BYC buy clips off whole CAT units: size 10 is 9,990 +mojos and size 25 is 24,975. Combine-first then preferred the tightest covering set +(two 25,025 coins → 49,950 needed, change 100). That leftover is below the 1 CAT +(1,000 mojo) dust floor, so the shaper returned `CannotFund` even with a remainder +coin that could have absorbed legal change. + +Lowering the dust floor to 0.1 CAT would allow the 100-mojo case but still block +20-mojo (size-10) and 50-mojo (single size-25) remainders, and would mint awkward +dust clips. + +## Decision + +1. **Keep the 1 CAT dust floor.** `coin_op_min_amount_mojos` stays 1,000 for CATs. +2. **Retry dusty covers once.** When a covering pick would leave CAT dust change + (solo oversize or a tight multi-coin set), re-select while skipping dusty + overshoots (`MinOvershoot`, cap intact) so leftover change lands on an extra + remainder coin — or on a different pair whose change is already legal. +3. **Fail closed** when no covering set within `combine_input_cap` leaves legal + change. Do not emit sub-CAT outputs. + +## Consequences + +- Two 25.025 clips plus a 4.930 remainder can fund two 24.975 buy clips (change 5.030). +- Two 25.025 clips alone still cannot fund that target. +- Daemon flat combine (no dust context) is unchanged: a solo covering pick is still + "not a combine." diff --git a/docs/progress.md b/docs/progress.md index 93789d3c..a79704cb 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -16,6 +16,12 @@ Pre-Rust migration detail lives in git history and ## Milestones +### 2026-08-12 — Combine dusty overshoot takes a remainder coin (ADR 0026) + +When a covering combine would leave CAT dust change, the shaper retries while skipping +dusty overshoots so leftover change lands on an extra remainder coin (or a different +pair with legal change). The 1 CAT dust floor is unchanged. + ### 2026-08-12 — Two-sided target spread (ADR 0025) `strategy_target_spread_bps` now offsets two-sided bid/ask around mid (buy below, sell diff --git a/greenfloor-engine/src/coin_ops/policy.rs b/greenfloor-engine/src/coin_ops/policy.rs index dd1dd46e..9ca31b60 100644 --- a/greenfloor-engine/src/coin_ops/policy.rs +++ b/greenfloor-engine/src/coin_ops/policy.rs @@ -48,6 +48,24 @@ pub fn overshoot_change_would_be_dust( ) } +/// CAT dust filter for covering-set selection (plan units or mojos). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DustChangeFilter<'a> { + pub mojo_multiplier: i64, + pub canonical_asset_id: &'a str, +} + +impl DustChangeFilter<'_> { + #[must_use] + pub(crate) fn change_is_dust(self, overshoot_amount: i64) -> bool { + overshoot_change_would_be_dust( + overshoot_amount, + self.mojo_multiplier, + self.canonical_asset_id, + ) + } +} + #[cfg(test)] mod tests { use super::{ diff --git a/greenfloor-engine/src/coin_ops/selection.rs b/greenfloor-engine/src/coin_ops/selection.rs index 8cb46b70..5be30aaa 100644 --- a/greenfloor-engine/src/coin_ops/selection.rs +++ b/greenfloor-engine/src/coin_ops/selection.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use super::policy::overshoot_change_would_be_dust; +pub(crate) use super::policy::DustChangeFilter; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetAmountOvershootRank { @@ -11,39 +12,42 @@ pub(crate) enum TargetAmountOvershootRank { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct TargetAmountSelectionOptions { +pub(crate) struct TargetAmountSelectionOptions<'a> { pub max_input_count: Option, pub min_input_count: usize, pub overshoot_rank: TargetAmountOvershootRank, + pub dust: Option>, } -impl Default for TargetAmountSelectionOptions { +impl Default for TargetAmountSelectionOptions<'_> { fn default() -> Self { Self { max_input_count: None, min_input_count: 1, overshoot_rank: TargetAmountOvershootRank::MinOvershoot, + dust: None, } } } -impl TargetAmountSelectionOptions { - /// Multi-coin combine retry: at least two inputs. Used when a solo covering pick would - /// leave CAT dust change (that coin belongs on the single-coin path only when change is - /// valid; when it is dust, force a multi-coin selection instead). - pub(crate) fn combine_multi_coin() -> Self { +impl<'a> TargetAmountSelectionOptions<'a> { + pub(crate) fn combine_cap(cap: usize) -> Self { Self { - max_input_count: None, + max_input_count: Some(cap), min_input_count: 2, - overshoot_rank: TargetAmountOvershootRank::MinOvershoot, + overshoot_rank: TargetAmountOvershootRank::MinInputCount, + dust: None, } } - pub(crate) fn combine_cap(cap: usize) -> Self { + /// Combine retry: at least two inputs, capped, skipping CAT-dust overshoot so leftover + /// change can land on an extra remainder coin. + pub(crate) fn combine_legal_change(cap: usize, dust: DustChangeFilter<'a>) -> Self { Self { max_input_count: Some(cap), min_input_count: 2, - overshoot_rank: TargetAmountOvershootRank::MinInputCount, + overshoot_rank: TargetAmountOvershootRank::MinOvershoot, + dust: Some(dust), } } } @@ -157,7 +161,7 @@ pub fn select_spendable_coins_for_target_amount( pub(crate) fn select_spendable_coins_for_target_amount_with_options( coins: &[SpendableCoin], target_amount: i64, - options: TargetAmountSelectionOptions, + options: TargetAmountSelectionOptions<'_>, ) -> (Vec, i64, bool) { let required = target_amount; if required <= 0 { @@ -167,7 +171,8 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( let TargetAmountSelectionOptions { max_input_count, min_input_count, - overshoot_rank, + dust, + .. } = options; if min_input_count == 0 || max_input_count.is_some_and(|max| max < min_input_count) { return (Vec::new(), 0, false); @@ -180,7 +185,7 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( let sum_cap = target_amount_sum_cap(required, &entries, max_input_count); if max_input_count.is_none() && sum_cap > 500_000 { - return greedy_target_amount_selection(&entries, required, min_input_count); + return greedy_target_amount_selection(&entries, required, min_input_count, dust); } let best = build_min_cardinality_subset_map(&entries, sum_cap, max_input_count); @@ -190,14 +195,7 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( return exact; } - choose_best_overshoot_subset( - &best, - &entries, - required, - min_input_count, - max_input_count, - overshoot_rank, - ) + choose_best_overshoot_subset(&best, &entries, required, options) } fn positive_spendable_entries(coins: &[SpendableCoin]) -> Vec<(String, i64)> { @@ -225,6 +223,7 @@ fn greedy_target_amount_selection( entries: &[(String, i64)], required: i64, min_input_count: usize, + dust: Option>, ) -> (Vec, i64, bool) { let mut ordered = entries.to_vec(); ordered.sort_by_key(|(_, amount)| std::cmp::Reverse(*amount)); @@ -234,7 +233,10 @@ fn greedy_target_amount_selection( picked_ids.push(coin_id); running += amount; if running >= required && picked_ids.len() >= min_input_count { - return (picked_ids, running, running == required); + let overshoot = running - required; + if !dust.is_some_and(|filter| filter.change_is_dust(overshoot)) { + return (picked_ids, running, running == required); + } } } (Vec::new(), 0, false) @@ -295,10 +297,14 @@ fn choose_best_overshoot_subset( best: &std::collections::BTreeMap>, entries: &[(String, i64)], required: i64, - min_input_count: usize, - max_input_count: Option, - overshoot_rank: TargetAmountOvershootRank, + options: TargetAmountSelectionOptions<'_>, ) -> (Vec, i64, bool) { + let TargetAmountSelectionOptions { + max_input_count, + min_input_count, + overshoot_rank, + dust, + } = options; let mut chosen: Option<(i64, Vec)> = None; for (sum, subset) in best { if *sum < required || subset.len() < min_input_count { @@ -307,6 +313,9 @@ fn choose_best_overshoot_subset( if max_input_count.is_some_and(|max| subset.len() > max) { continue; } + if dust.is_some_and(|filter| filter.change_is_dust(*sum - required)) { + continue; + } if chosen.as_ref().is_none_or(|(best_sum, best_subset)| { overshoot_subset_better( *sum, @@ -445,4 +454,34 @@ mod tests { HashSet::from(["sixtyfive", "twenty", "ten_a", "ten_b"].map(str::to_string)) ); } + + #[test] + fn legal_change_combine_skips_dusty_two_coin_cover_and_takes_remainder() { + let list = coins(&[ + ("old25_a", 25_025), + ("old25_b", 25_025), + ("dust_fragment", 50), + ("remainder", 4_930), + ]); + let (ids, total, exact) = select_spendable_coins_for_target_amount_with_options( + &list, + 49_950, + TargetAmountSelectionOptions::combine_legal_change( + 5, + DustChangeFilter { + mojo_multiplier: 1, + canonical_asset_id: + "0000000000000000000000000000000000000000000000000000000000000001", + }, + ), + ); + assert!(!exact); + assert_eq!(total, 54_980); + assert_eq!(ids.len(), 3); + let set: HashSet<_> = ids.into_iter().collect(); + assert_eq!( + set, + HashSet::from(["old25_a", "old25_b", "remainder"].map(str::to_string)) + ); + } } diff --git a/greenfloor-engine/src/coin_ops/shape/combine.rs b/greenfloor-engine/src/coin_ops/shape/combine.rs index ff83deff..39112ee1 100644 --- a/greenfloor-engine/src/coin_ops/shape/combine.rs +++ b/greenfloor-engine/src/coin_ops/shape/combine.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use super::types::{CombineInputs, ShapeCoin}; use crate::coin_ops::overshoot_change_would_be_dust; use crate::coin_ops::selection::{ - select_spendable_coins_for_target_amount_with_options, SpendableCoin, + select_spendable_coins_for_target_amount_with_options, DustChangeFilter, SpendableCoin, TargetAmountSelectionOptions, }; use crate::metrics::metric_non_negative_usize; @@ -24,10 +24,12 @@ pub fn plan_combine_inputs_for_target( /// [`plan_combine_inputs_for_target`] restricted to `allowed_coin_ids` when provided. /// -/// When `dust` is set and the unconstrained pick is a single covering coin whose overshoot -/// would be CAT dust, retries with a forced multi-coin selection so dust oversize + fragments -/// can still combine. Without `dust` (daemon flat combine), a solo covering pick still means -/// "not a combine" — matching the historical skip when only protected singles cover. +/// When `dust` is set and the covering pick would leave CAT dust change (solo oversize or +/// a tight multi-coin cover), retries once while skipping dusty overshoots so leftover +/// change can land on an extra remainder coin — instead of lowering the CAT dust floor. +/// +/// Without `dust` (daemon flat combine), a solo covering pick still means "not a combine" +/// — matching the historical skip when only protected singles cover. #[must_use] pub(crate) fn plan_combine_inputs_for_target_in( coins: &[ShapeCoin], @@ -44,10 +46,12 @@ pub(crate) fn plan_combine_inputs_for_target_in( ) } -#[derive(Clone, Copy)] -struct CombineDustContext<'a> { - mojo_multiplier: i64, - canonical_asset_id: &'a str, +fn cover_change_is_dust( + selected_total: i64, + target_amount: i64, + dust: Option>, +) -> bool { + dust.is_some_and(|filter| filter.change_is_dust(selected_total.saturating_sub(target_amount))) } fn plan_combine_inputs_for_target_in_with_dust( @@ -55,7 +59,7 @@ fn plan_combine_inputs_for_target_in_with_dust( target_amount: i64, combine_input_cap: i64, allowed_coin_ids: Option<&HashSet>, - dust: Option>, + dust: Option>, ) -> Option { if target_amount <= 0 { return None; @@ -76,37 +80,27 @@ fn plan_combine_inputs_for_target_in_with_dust( .map(|coin| SpendableCoin::new(coin.id.clone(), coin.amount)) .collect(); - let (mut unconstrained_ids, mut unconstrained_total, mut unconstrained_exact) = + let (unconstrained_ids, unconstrained_total, unconstrained_exact) = select_spendable_coins_for_target_amount_with_options( &spendable, target_amount, TargetAmountSelectionOptions::default(), ); - let mut selected_count_before_cap = unconstrained_ids.len(); + let selected_count_before_cap = unconstrained_ids.len(); if selected_count_before_cap < 2 { - let retry_multi = selected_count_before_cap == 1 - && dust.is_some_and(|ctx| { - overshoot_change_would_be_dust( - unconstrained_total.saturating_sub(target_amount), - ctx.mojo_multiplier, - ctx.canonical_asset_id, - ) - }); - if !retry_multi { + let dusty_single = selected_count_before_cap == 1 + && cover_change_is_dust(unconstrained_total, target_amount, dust); + if !dusty_single { return None; } - let (ids, total, exact) = select_spendable_coins_for_target_amount_with_options( + return select_legal_change_cover( &spendable, target_amount, - TargetAmountSelectionOptions::combine_multi_coin(), + combine_input_cap, + cap, + dust?, + selected_count_before_cap, ); - if ids.len() < 2 { - return None; - } - unconstrained_ids = ids; - unconstrained_total = total; - unconstrained_exact = exact; - selected_count_before_cap = unconstrained_ids.len(); } let cap_applied = selected_count_before_cap > cap; @@ -136,13 +130,52 @@ fn plan_combine_inputs_for_target_in_with_dust( ); } - Some(CombineInputs { - input_coin_ids, - selected_total, + if !cover_change_is_dust(selected_total, target_amount, dust) { + return Some(CombineInputs { + input_coin_ids, + selected_total, + target_amount, + exact_match, + cap_applied, + selected_count_before_cap, + combine_input_cap, + }); + } + select_legal_change_cover( + &spendable, target_amount, - exact_match, - cap_applied, + combine_input_cap, + cap, + dust?, selected_count_before_cap, + ) +} + +/// Re-select a covering set that skips CAT-dust overshoot so leftover change can land on +/// an extra remainder coin (or a different pair whose change is already legal). +fn select_legal_change_cover( + spendable: &[SpendableCoin], + target_amount: i64, + combine_input_cap: i64, + cap: usize, + dust: DustChangeFilter<'_>, + selected_count_before_cap: usize, +) -> Option { + let (ids, total, exact) = select_spendable_coins_for_target_amount_with_options( + spendable, + target_amount, + TargetAmountSelectionOptions::combine_legal_change(cap, dust), + ); + if ids.len() < 2 { + return None; + } + Some(CombineInputs { + selected_count_before_cap: selected_count_before_cap.max(ids.len()), + input_coin_ids: ids, + selected_total: total, + target_amount, + exact_match: exact, + cap_applied: selected_count_before_cap > cap, combine_input_cap, }) } @@ -192,7 +225,7 @@ fn combine_with_dust_guard( target_amount, combine_input_cap, allowed_coin_ids, - Some(CombineDustContext { + Some(DustChangeFilter { mojo_multiplier, canonical_asset_id, }), @@ -344,6 +377,60 @@ mod tests { .is_none()); } + #[test] + fn dusty_two_clip_combine_takes_third_coin_for_legal_change() { + let spendable = coins(&[ + ("old25_a", 25_025), + ("old25_b", 25_025), + ("dust_fragment", 50), + ("remainder", 4_930), + ]); + let plan = plan_ladder_preserving_combine( + &spendable, + &HashMap::new(), + 49_950, + 5, + 1, + TEST_CAT_ASSET_ID, + ) + .expect("remainder coin absorbs dust change"); + assert_eq!(plan.input_coin_ids.len(), 3); + assert_eq!(plan.selected_total, 54_980); + assert!(plan.input_coin_ids.contains(&"remainder".to_string())); + assert!(!plan.input_coin_ids.contains(&"dust_fragment".to_string())); + } + + #[test] + fn dusty_two_clip_combine_without_remainder_still_rejected() { + let spendable = coins(&[("old25_a", 25_025), ("old25_b", 25_025)]); + assert!(plan_ladder_preserving_combine( + &spendable, + &HashMap::new(), + 49_950, + 5, + 1, + TEST_CAT_ASSET_ID, + ) + .is_none()); + } + + #[test] + fn dusty_two_clip_prefers_alternate_pair_with_legal_change() { + let spendable = coins(&[("old25_a", 25_025), ("old25_b", 25_025), ("larger", 26_000)]); + let plan = plan_ladder_preserving_combine( + &spendable, + &HashMap::new(), + 49_950, + 5, + 1, + TEST_CAT_ASSET_ID, + ) + .expect("alternate pair leaves legal change"); + assert_eq!(plan.input_coin_ids.len(), 2); + assert_eq!(plan.selected_total, 51_025); + assert!(plan.input_coin_ids.contains(&"larger".to_string())); + } + #[test] fn preserving_ladder_combine_minimizes_ten_bu_inputs_for_eco181() { use crate::coin_ops::shape::protected_slots_for_rows; From e344352585fa4203fa36e6eeb8ab4586aeb3591f Mon Sep 17 00:00:00 2001 From: Gene Hoffman Date: Wed, 12 Aug 2026 17:01:59 -0700 Subject: [PATCH 2/2] Drop dead greedy dust filtering and make dusty-solo combine retry explicit. The legal-change selector always sets an input cap, so dust never reached the greedy path; DustChangeFilter now enters combine from policy instead of a selection re-export. --- greenfloor-engine/src/coin_ops/mod.rs | 1 + greenfloor-engine/src/coin_ops/selection.rs | 12 +-- .../src/coin_ops/shape/combine.rs | 91 +++++++++---------- 3 files changed, 49 insertions(+), 55 deletions(-) diff --git a/greenfloor-engine/src/coin_ops/mod.rs b/greenfloor-engine/src/coin_ops/mod.rs index 317eb176..56125d25 100644 --- a/greenfloor-engine/src/coin_ops/mod.rs +++ b/greenfloor-engine/src/coin_ops/mod.rs @@ -43,6 +43,7 @@ pub use plan::{ plan_coin_ops, BucketSpec, CoinOpKind, CoinOpPlan, CoinOpPlanReason, CoinOpPlanningResult, LadderTargetRow, }; +pub(crate) use policy::DustChangeFilter; pub use policy::{ amount_meets_coin_op_min_mojos, cat_overshoot_change_would_be_dust, coin_op_min_amount_mojos, coin_op_target_amount_allowed, overshoot_change_would_be_dust, diff --git a/greenfloor-engine/src/coin_ops/selection.rs b/greenfloor-engine/src/coin_ops/selection.rs index 5be30aaa..942adf54 100644 --- a/greenfloor-engine/src/coin_ops/selection.rs +++ b/greenfloor-engine/src/coin_ops/selection.rs @@ -2,8 +2,7 @@ use std::collections::HashSet; -use super::policy::overshoot_change_would_be_dust; -pub(crate) use super::policy::DustChangeFilter; +use super::policy::{overshoot_change_would_be_dust, DustChangeFilter}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetAmountOvershootRank { @@ -171,7 +170,6 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( let TargetAmountSelectionOptions { max_input_count, min_input_count, - dust, .. } = options; if min_input_count == 0 || max_input_count.is_some_and(|max| max < min_input_count) { @@ -185,7 +183,7 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( let sum_cap = target_amount_sum_cap(required, &entries, max_input_count); if max_input_count.is_none() && sum_cap > 500_000 { - return greedy_target_amount_selection(&entries, required, min_input_count, dust); + return greedy_target_amount_selection(&entries, required, min_input_count); } let best = build_min_cardinality_subset_map(&entries, sum_cap, max_input_count); @@ -223,7 +221,6 @@ fn greedy_target_amount_selection( entries: &[(String, i64)], required: i64, min_input_count: usize, - dust: Option>, ) -> (Vec, i64, bool) { let mut ordered = entries.to_vec(); ordered.sort_by_key(|(_, amount)| std::cmp::Reverse(*amount)); @@ -233,10 +230,7 @@ fn greedy_target_amount_selection( picked_ids.push(coin_id); running += amount; if running >= required && picked_ids.len() >= min_input_count { - let overshoot = running - required; - if !dust.is_some_and(|filter| filter.change_is_dust(overshoot)) { - return (picked_ids, running, running == required); - } + return (picked_ids, running, running == required); } } (Vec::new(), 0, false) diff --git a/greenfloor-engine/src/coin_ops/shape/combine.rs b/greenfloor-engine/src/coin_ops/shape/combine.rs index 39112ee1..00b194a6 100644 --- a/greenfloor-engine/src/coin_ops/shape/combine.rs +++ b/greenfloor-engine/src/coin_ops/shape/combine.rs @@ -3,11 +3,11 @@ use std::collections::{HashMap, HashSet}; use super::types::{CombineInputs, ShapeCoin}; -use crate::coin_ops::overshoot_change_would_be_dust; use crate::coin_ops::selection::{ - select_spendable_coins_for_target_amount_with_options, DustChangeFilter, SpendableCoin, + select_spendable_coins_for_target_amount_with_options, SpendableCoin, TargetAmountSelectionOptions, }; +use crate::coin_ops::DustChangeFilter; use crate::metrics::metric_non_negative_usize; /// Select combine inputs covering `target_amount` from all of `coins` (no ladder-row @@ -46,14 +46,6 @@ pub(crate) fn plan_combine_inputs_for_target_in( ) } -fn cover_change_is_dust( - selected_total: i64, - target_amount: i64, - dust: Option>, -) -> bool { - dust.is_some_and(|filter| filter.change_is_dust(selected_total.saturating_sub(target_amount))) -} - fn plan_combine_inputs_for_target_in_with_dust( coins: &[ShapeCoin], target_amount: i64, @@ -88,19 +80,22 @@ fn plan_combine_inputs_for_target_in_with_dust( ); let selected_count_before_cap = unconstrained_ids.len(); if selected_count_before_cap < 2 { - let dusty_single = selected_count_before_cap == 1 - && cover_change_is_dust(unconstrained_total, target_amount, dust); - if !dusty_single { - return None; + // A legal solo cover is "not a combine". Only a dusty solo cover retries. + if let Some(filter) = dust { + if selected_count_before_cap == 1 + && filter.change_is_dust(unconstrained_total.saturating_sub(target_amount)) + { + return select_legal_change_cover( + &spendable, + target_amount, + combine_input_cap, + cap, + filter, + selected_count_before_cap, + ); + } } - return select_legal_change_cover( - &spendable, - target_amount, - combine_input_cap, - cap, - dust?, - selected_count_before_cap, - ); + return None; } let cap_applied = selected_count_before_cap > cap; @@ -130,25 +125,27 @@ fn plan_combine_inputs_for_target_in_with_dust( ); } - if !cover_change_is_dust(selected_total, target_amount, dust) { - return Some(CombineInputs { - input_coin_ids, - selected_total, - target_amount, - exact_match, - cap_applied, - selected_count_before_cap, - combine_input_cap, - }); + if let Some(filter) = dust { + if filter.change_is_dust(selected_total.saturating_sub(target_amount)) { + return select_legal_change_cover( + &spendable, + target_amount, + combine_input_cap, + cap, + filter, + selected_count_before_cap, + ); + } } - select_legal_change_cover( - &spendable, + Some(CombineInputs { + input_coin_ids, + selected_total, target_amount, - combine_input_cap, - cap, - dust?, + exact_match, + cap_applied, selected_count_before_cap, - ) + combine_input_cap, + }) } /// Re-select a covering set that skips CAT-dust overshoot so leftover change can land on @@ -220,20 +217,22 @@ fn combine_with_dust_guard( canonical_asset_id: &str, allowed_coin_ids: Option<&HashSet>, ) -> Option { + let filter = DustChangeFilter { + mojo_multiplier, + canonical_asset_id, + }; let selection = plan_combine_inputs_for_target_in_with_dust( coins, target_amount, combine_input_cap, allowed_coin_ids, - Some(DustChangeFilter { - mojo_multiplier, - canonical_asset_id, - }), + Some(filter), )?; - let change = selection - .selected_total - .saturating_sub(selection.target_amount); - if overshoot_change_would_be_dust(change, mojo_multiplier, canonical_asset_id) { + if filter.change_is_dust( + selection + .selected_total + .saturating_sub(selection.target_amount), + ) { return None; } Some(selection)