feat: anchor-fill UTXO selector for consolidation withdrawals#281
Conversation
| // 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) |
There was a problem hiding this comment.
We could add something like if max_gas_fee < 5000 * num_output + orchard_offset { return 0; } here
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Also, it would be good to simulate how often our UTXO selection would run into conflicts during parallel selection.
The relayer can pass the adjusted max_gas_fee, for example 0.75 or 0.5 of it, not the real max_gas_fee |
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>
|
@copilot resolve the merge conflicts in this pull request |
Done. Merged
Build and tests are clean. |
Summary
Adds
choose_utxos_anchor_fill— a new UTXO selector for bridge withdrawalsthat picks the maximum number of inputs allowed by
max_gas_feeratherthan 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:K-1smaller UTXOs we want to consume (smallest-firstmaximises consolidation).
sum_inputs ≈ net_amount, leaving avalid single change in
[min_change_amount, min(max_change_amount, min_filler)).For each
KfromK_maxdown to1:get_gas_fee(K, 1, …)) exceeds the budget.(K-1)-wide filler window across the sorted pool; binary-search thetail for an anchor that lands the total in the valid window (either exact
absorption or single-piece change).
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
valid_change_max = min(max_change_amount, min_filler). Nosplit_changecalls; 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.pool_size > passive_management_upper_limit) — pool isfiltered to
balance ≤ net_amountupfront so theinput_num > change_numrule is structurally satisfied.
pool_size < passive_management_lower_limit) — selectorreturns
Errupfront. LOW zone requiresinput_num < change_num, which isunreachable when change_num ≤ 1; caller should run active utxo management
instead.
Trade-offs vs
choose_utxos_randomSweep on a 100-UTXO tiered pool, amount 1.5M sat, fee_rate 3000:
choose_utxos_randomtypically picks 1 input + 1 change for the same setupregardless of budget. With anchor-fill, an over-funded
max_gas_feeactuallygets used to consume small UTXOs.
Test plan
anchor_fill_sweep_input_count_vs_budget— table sweep showing inputcounts 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 smoketest.
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_numberbound wouldsilently reject budgets that real 2-output selections fit easily).
anchor_fill_errors_when_budget_too_small— too-tight budget returnsa clear error.
anchor_fill_rejects_low_zone— LOW-zone calls must Err with apointer to active utxo management.
anchor_fill_never_produces_more_than_two_outputs— invariant sweepacross budget range.
cargo build+cargo clippy -p utxo-utilsclean.