Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
34 changes: 34 additions & 0 deletions docs/decisions/0026-combine-dust-remainder-coin.md
Original file line number Diff line number Diff line change
@@ -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."
6 changes: 6 additions & 0 deletions docs/progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions greenfloor-engine/src/coin_ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions greenfloor-engine/src/coin_ops/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
83 changes: 58 additions & 25 deletions greenfloor-engine/src/coin_ops/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<usize>,
pub min_input_count: usize,
pub overshoot_rank: TargetAmountOvershootRank,
pub dust: Option<DustChangeFilter<'a>>,
}

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),
}
}
}
Expand Down Expand Up @@ -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<String>, i64, bool) {
let required = target_amount;
if required <= 0 {
Expand All @@ -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);
Expand All @@ -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)> {
Expand Down Expand Up @@ -295,10 +291,14 @@ fn choose_best_overshoot_subset(
best: &std::collections::BTreeMap<i64, Vec<usize>>,
entries: &[(String, i64)],
required: i64,
min_input_count: usize,
max_input_count: Option<usize>,
overshoot_rank: TargetAmountOvershootRank,
options: TargetAmountSelectionOptions<'_>,
) -> (Vec<String>, i64, bool) {
let TargetAmountSelectionOptions {
max_input_count,
min_input_count,
overshoot_rank,
dust,
} = options;
let mut chosen: Option<(i64, Vec<usize>)> = None;
for (sum, subset) in best {
if *sum < required || subset.len() < min_input_count {
Expand All @@ -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,
Expand Down Expand Up @@ -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))
);
}
}
Loading
Loading