Skip to content

feat: anchor-fill UTXO selector for consolidation withdrawals#281

Merged
karim-en merged 18 commits into
mainfrom
utxo-consolidating
May 30, 2026
Merged

feat: anchor-fill UTXO selector for consolidation withdrawals#281
karim-en merged 18 commits into
mainfrom
utxo-consolidating

Conversation

@karim-en

@karim-en karim-en commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds choose_utxos_anchor_fill — a new UTXO selector for bridge withdrawals
that picks the maximum number of inputs allowed by max_gas_fee rather
than the minimum. Each withdrawal then doubles as a consolidation step, so
the pool drifts toward fewer, larger UTXOs over time instead of fragmenting
with every change output.

Lives alongside the existing choose_utxos_random; callers opt in by name.

How it works

Every selection has the shape K-1 fillers + 1 anchor:

  • fillersK-1 smaller UTXOs we want to consume (smallest-first
    maximises consolidation).
  • anchor — one larger UTXO sized so sum_inputs ≈ net_amount, leaving a
    valid single change in [min_change_amount, min(max_change_amount, min_filler)).

For each K from K_max down to 1:

  1. Skip if even the best-case fee (get_gas_fee(K, 1, …)) exceeds the budget.
  2. Slide a (K-1)-wide filler window across the sorted pool; binary-search the
    tail for an anchor that lands the total in the valid window (either exact
    absorption or single-piece change).
  3. Compute the actual fee using the real output count produced (1 target +
    0-or-1 change) and reject if it exceeds max_gas_fee.

The "max within budget" strategy is the opposite of the existing min-fee
random selector. Used together with active utxo management, it keeps the
pool consolidated as a side-effect of normal withdrawals.

Constraints honored

  • 1 target + ≤1 change — never splits change into multiple pieces.
    valid_change_max = min(max_change_amount, min_filler). No split_change
    calls; selector rejects K/anchor combos whose change would need to split.
  • change_i < min_input — enforced by the anchor window.
  • change_i ∈ [min_change_amount, max_change_amount) — same.
  • HIGH zone (pool_size > passive_management_upper_limit) — pool is
    filtered to balance ≤ net_amount upfront so the input_num > change_num
    rule is structurally satisfied.
  • LOW zone (pool_size < passive_management_lower_limit) — selector
    returns Err upfront. LOW zone requires input_num < change_num, which is
    unreachable when change_num ≤ 1; caller should run active utxo management
    instead.

Trade-offs vs choose_utxos_random

Sweep on a 100-UTXO tiered pool, amount 1.5M sat, fee_rate 3000:

max_gas_fee inputs outputs actual fee
200000 23 2 4939
100000 23 2 4939
50000 23 2 4939
20000 23 2 4939
10000 23 2 4939
5000 23 2 4939
3000 13 2 2947
2000 8 2 1951
1000 3 2 955
500 (infeasible — best-case 1-input fee exceeds budget)

choose_utxos_random typically picks 1 input + 1 change for the same setup
regardless of budget. With anchor-fill, an over-funded max_gas_fee actually
gets used to consume small UTXOs.

Test plan

  • anchor_fill_sweep_input_count_vs_budget — table sweep showing input
    counts climb with budget; every selection passes invariant checks
    (uniqueness, balance equation, single-piece change, fee ≤ budget).
  • anchor_fill_picks_more_inputs_at_larger_budget — monotonicity smoke
    test.
  • anchor_fill_accepts_budget_that_pessimistic_bound_would_reject
    regression test pinning the per-candidate fee check (an earlier
    upfront worst_case_num_output = 1 + max_change_number bound would
    silently reject budgets that real 2-output selections fit easily).
  • anchor_fill_errors_when_budget_too_small — too-tight budget returns
    a clear error.
  • anchor_fill_rejects_low_zone — LOW-zone calls must Err with a
    pointer to active utxo management.
  • anchor_fill_never_produces_more_than_two_outputs — invariant sweep
    across budget range.
  • cargo build + cargo clippy -p utxo-utils clean.

// fee = 5000 * max(num_input, num_output) + (orchard ? 5000 : 0)
let orchard_offset = if orchard { 5000u64 } else { 0 };
let budget = max_gas_fee.saturating_sub(orchard_offset);
usize::try_from(budget / 5000).unwrap_or(usize::MAX)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add something like if max_gas_fee < 5000 * num_output + orchard_offset { return 0; } here

@karim-en karim-en marked this pull request as ready for review May 24, 2026 12:35
@karim-en karim-en requested a review from a team as a code owner May 24, 2026 12:35
karim-en and others added 2 commits May 24, 2026 13:43
Thread the optional gas budget from `near_submit_btc_transfer` down to
`extract_utxo`. When the caller sets `max_gas_fee`, use the new
`choose_utxos_anchor_fill` selector so the withdrawal consolidates small
UTXOs within the budget; otherwise fall back to `choose_utxos_random_no_payment`
for the existing min-fee behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@olga24912 olga24912 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I don’t like is that on small transactions — where we already significantly increased the gas fee to avoid transactions getting stuck — we’re now also replacing a single input with 25 inputs. At that point, the fee becomes disproportionately large compared to the transaction itself.

@olga24912 olga24912 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it would be good to simulate how often our UTXO selection would run into conflicts during parallel selection.

Comment thread bridge-sdk/connectors/omni-connector/src/omni_connector.rs
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs Outdated
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs Outdated
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs Outdated
@karim-en

Copy link
Copy Markdown
Collaborator Author

What I don’t like is that on small transactions — where we already significantly increased the gas fee to avoid transactions getting stuck — we’re now also replacing a single input with 25 inputs. At that point, the fee becomes disproportionately large compared to the transaction itself.

The relayer can pass the adjusted max_gas_fee, for example 0.75 or 0.5 of it, not the real max_gas_fee

karim-en and others added 3 commits May 26, 2026 16:56
Finish what the relaxed-restrictions commit started. The relaxation
raised the per-piece cap from `min_filler` to `max_change_amount` so
the surplus could fit in one large change piece, but it kept the
`split_change` fallback and an upper window of
`max_change_number * max_change_amount` — so multi-piece change was
still reachable on extreme inputs, and the docstring's promise of
"a single large change piece" was only statistical.

  * Tighten `valid_change_max` in `try_anchor_search` to a single
    piece (`max_change_amount`). Anchors whose change would span
    multiple pieces are now outside the window and rejected; the
    algorithm falls back to a smaller K.
  * Remove the `split_change` branch from both `try_single_input`
    and `try_anchor_search`; the `split_change` import drops from
    the module entirely.
  * Replace the single-anchor loop in `try_anchor_search` with a
    direct smallest-viable lookup.
  * Drop the `enforce_passive_management` call: HIGH zone is
    enforced inline (pool filter + K >= 2 trivially satisfies
    `input_num > change_num` with at most one change piece); LOW
    zone now bails upfront — it requires `input_num < change_num`,
    structurally impossible when `change_num <= 1`. Callers should
    run active utxo management instead of relying on a withdrawal
    to consolidate.
  * Add a `debug_assert!(actual_num_output <= 2)` on the happy path
    so a future regression is caught loudly.
  * Update tests: the two LOW-zone tests that previously expected
    multi-piece success now expect Err; a new
    `anchor_fill_never_produces_more_than_two_outputs` test sweeps
    the full budget range and asserts the invariant directly.

Every successful selection is now exactly 1 target + 0-or-1 change.
The docstring's "single large change piece" claim is a hard
guarantee, not a likelihood.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@karim-en

Copy link
Copy Markdown
Collaborator Author

@copilot resolve the merge conflicts in this pull request

Copilot AI commented May 30, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Done. Merged origin/main into this branch (commit 9b6f6a9). The four conflict regions in omni_connector.rs were resolved by combining both sides:

  • near_submit_btc_transfer: signature now takes max_gas_fee, change_reserve, and memo; all three are forwarded to extract_utxo
  • near_submit_btc_transfer_with_tx_hash: passes memo from main alongside the existing None for change_reserve
  • extract_utxo: signature takes all three new parameters; anchor-fill dispatch + memo/orchard handling are both preserved

Build and tests are clean.

@karim-en karim-en requested a review from olga24912 May 30, 2026 12:35
Comment thread bridge-sdk/connectors/omni-connector/src/omni_connector.rs
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs Outdated
Comment thread bridge-sdk/utxo-utils/src/anchor_fill.rs Outdated
@karim-en karim-en merged commit 3a64c55 into main May 30, 2026
6 checks passed
@frolvanya frolvanya mentioned this pull request Jun 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants