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/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/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..942adf54 100644 --- a/greenfloor-engine/src/coin_ops/selection.rs +++ b/greenfloor-engine/src/coin_ops/selection.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; -use super::policy::overshoot_change_would_be_dust; +use super::policy::{overshoot_change_would_be_dust, DustChangeFilter}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TargetAmountOvershootRank { @@ -11,39 +11,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 +160,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 +170,7 @@ pub(crate) fn select_spendable_coins_for_target_amount_with_options( let TargetAmountSelectionOptions { max_input_count, min_input_count, - overshoot_rank, + .. } = options; if min_input_count == 0 || max_input_count.is_some_and(|max| max < min_input_count) { return (Vec::new(), 0, false); @@ -190,14 +193,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)> { @@ -295,10 +291,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 +307,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 +448,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..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, 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 @@ -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,18 +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 plan_combine_inputs_for_target_in_with_dust( coins: &[ShapeCoin], 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 +72,30 @@ 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 { - return None; - } - let (ids, total, exact) = select_spendable_coins_for_target_amount_with_options( - &spendable, - target_amount, - TargetAmountSelectionOptions::combine_multi_coin(), - ); - if ids.len() < 2 { - 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, + ); + } } - unconstrained_ids = ids; - unconstrained_total = total; - unconstrained_exact = exact; - selected_count_before_cap = unconstrained_ids.len(); + return None; } let cap_applied = selected_count_before_cap > cap; @@ -136,6 +125,18 @@ fn plan_combine_inputs_for_target_in_with_dust( ); } + 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, + ); + } + } Some(CombineInputs { input_coin_ids, selected_total, @@ -147,6 +148,35 @@ fn plan_combine_inputs_for_target_in_with_dust( }) } +/// 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, + }) +} + /// Partition `coins` into (eligible, excluded) by `protected_slots` (`size -> reserved count`). /// /// Excluded coins are sorted smallest-amount-first so callers can release them one at a time @@ -187,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(CombineDustContext { - 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) @@ -344,6 +376,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;