Skip to content

fix: precise fee estimation and oscillation protection (Supersedes #195)#264

Merged
louisinger merged 29 commits intoarkade-os:masterfrom
jgmcalpine:fix/fee-estimation-logic
Jan 21, 2026
Merged

fix: precise fee estimation and oscillation protection (Supersedes #195)#264
louisinger merged 29 commits intoarkade-os:masterfrom
jgmcalpine:fix/fee-estimation-logic

Conversation

@jgmcalpine
Copy link
Contributor

@jgmcalpine jgmcalpine commented Jan 19, 2026

Description

This PR implements precise fee estimation using address decoding and resolves the "dust oscillation" loop reported by CodeRabbit to close #63.

Reason for new PR:
This supersedes PR #195 due to inactivity. I have built upon the original work by @shubertm and added fixes requested by @louisinger and CodeRabbit which were blocking the merge.

Changes

  1. Precise Script Sizes: Replaced string heuristics with @scure/btc-signer address decoding. This ensures correct fee calculation for Legacy, Segwit, and Taproot inputs/outputs (Fixes @louisinger feedback).
  2. Oscillation Fix: Refactored estimateFeesAndSelectCoins to handle the "dust paradox" where removing a change output drops the fee below the budget. This prevents infinite loops/timeouts.
  3. Tests: Added unit tests ensuring:
    • Segwit outputs are calculated as smaller than Taproot.
    • The fee estimator converges successfully in dust-boundary edge cases.

Co-authored-by: @shubertm

Summary by CodeRabbit

  • New Features

    • Enhanced transaction-size estimator with more accurate address-based output sizing and varint sizing helper.
  • Behavior Changes

    • Send, bump and unroll flows use convergence-based fee/coin selection; wallet avoids creating change outputs below the dust threshold.
    • Dust threshold exposed for consistent handling.
  • Bug Fixes / Refinements

    • Minor on-chain validation/refactor to ensure explicit boolean returns in block-tip checks.
  • Tests

    • Expanded wallet tests for insufficient funds, dust validation, change-edge cases, and fee/size scenarios.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

Walkthrough

Implements iterative fee estimation and coin-selection in OnchainWallet, adds address-based output sizing to the tx size estimator, centralizes DUST_AMOUNT, updates send/unroll/bump flows to be dust-aware, minor provider refactor, and expands wallet tests for fee/change/dust scenarios.

Changes

Cohort / File(s) Summary
Provider Logic
src/providers/onchain.ts
Minor refactor: isValidBlocksTip inner arrow now explicitly returns a boolean (control-flow-only change).
Tx size estimator
src/utils/txSizeEstimator.ts
Added top-level getVarIntSize; renamed P2WKH_OUTPUT_SIZEP2WPKH_OUTPUT_SIZE; introduced addOutputScript / addOutputAddress; refactored tapscript witness sizing and vsize to use shared varint helper.
Onchain wallet
src/wallet/onchain.ts
Added estimateFeesAndSelectCoins(...) (iterative fee estimation + coin selection); removed class-level DUST_AMOUNT; send and bumpP2A updated to use convergent fee estimation and address-based output sizing.
Unroll & utils
src/wallet/unroll.ts, src/wallet/utils.ts
Exported DUST_AMOUNT = 546; unroll now uses addOutputAddress for sizing and enforces dust threshold for output creation.
Tests
test/wallet.test.ts
Expanded send-related tests: insufficient funds, dust-limit validation, change-dust scenarios, fee correctness across UTXO mixes and tx types, additional mocks and data.

Sequence Diagram(s)

sequenceDiagram
    participant Caller as Caller (send / bump)
    participant FeeEstimator as estimateFeesAndSelectCoins
    participant CoinSelector as selectCoins
    participant TxEstimator as TxWeightEstimator
    participant Validator as Dust/Fee Validator
    participant Broadcaster as broadcast()

    Caller->>FeeEstimator: coins, amount, feeRate, recipientAddress
    loop converge
        FeeEstimator->>CoinSelector: request selection for target (amount + feeGuess)
        CoinSelector-->>FeeEstimator: selectedCoins
        FeeEstimator->>TxEstimator: addInput(...) for each selected coin
        FeeEstimator->>TxEstimator: addOutputAddress(recipient)
        FeeEstimator->>TxEstimator: addOutputAddress(change) if changeGuess ≥ 0
        FeeEstimator->>TxEstimator: vsize()
        TxEstimator-->>FeeEstimator: estimatedVSize
        FeeEstimator->>Validator: compute fee = vsize * feeRate, check dust/convergence
        Validator-->>FeeEstimator: converge / adjust feeGuess or fail
    end
    FeeEstimator-->>Caller: inputs, changeAmount, fee
    Caller->>Broadcaster: submit raw tx
    Broadcaster-->>Caller: txid
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • louisinger
  • tiero
🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: implementing precise fee estimation and adding oscillation protection, which aligns with the main refactoring of fee calculation logic across the codebase.
Linked Issues check ✅ Passed The PR successfully replaces hardcoded transaction size with dynamic computation using TxWeightEstimator, accurately calculating sizes for Legacy/Segwit/Taproot via address decoding, directly addressing issue #63 requirements.
Out of Scope Changes check ✅ Passed All changes relate to precise fee estimation and dust handling: refactored TxWeightEstimator, new output methods, convergence-based fee selection, and comprehensive tests—all in scope with linked issue #63.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/wallet/unroll.ts`:
- Line 302: The call to TxWeightEstimator.addTxOutput is invalid; replace it
with the address-based API by calling TxWeightEstimator.addOutputAddress and
pass the wallet network as the second argument so sizing uses the correct
network context (e.g., replace usages of addTxOutput(outputAddress) with
addOutputAddress(outputAddress, wallet.network) — adjust the wallet network
accessor name if your wallet object exposes it under a different property).

In `@test/wallet.test.ts`:
- Around line 266-471: The tests mock Esplora fee responses as a plain number
but EsploraProvider.getFeeRate expects an object with fees["1"], so update all
fee-rate mocks in these tests (calls that currently resolve to mockFeeRate) to
return an object with the "1" key (e.g. { "1": mockFeeRate }) so
EsploraProvider.getFeeRate reads the correct value; search for
mockFetch.mockResolvedValueOnce(...) returning mockFeeRate in this file and
replace each with the map-shaped response to validate variable fee-rate
behavior.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/utils/txSizeEstimator.ts`:
- Around line 16-21: The getVarIntSize function has off-by-one bugs at the
boundary checks; update its comparisons so boundary values are handled
inclusively (e.g., change checks to n <= 0xffff and n <= 0xffffffff) or use
upper-exclusive thresholds like n < 0x10000 and n < 0x100000000 to correctly
return 3 bytes for 0xffff and 5 bytes for 0xffffffff; modify the getVarIntSize
implementation accordingly to ensure each VarInt range maps to the correct byte
size.

Copy link
Collaborator

@louisinger louisinger left a comment

Choose a reason for hiding this comment

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

thanks! LGTM except some nit comments

@jgmcalpine jgmcalpine requested a review from louisinger January 20, 2026 15:49
Array.isArray(tip) &&
tip.every((t) => {
t &&
return (
Copy link
Contributor

Choose a reason for hiding this comment

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

This is quite an important fix: isValidBlocksTip has been returning false all the time.

Copy link
Contributor

@shubertm shubertm left a comment

Choose a reason for hiding this comment

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

LGTM. Thanks @jgmcalpine

* @param n - The integer value to check
* @returns The size in bytes (1, 3, 5, or 9)
*/
const getVarIntSize = (n: number): number => {
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

*/
addOutputScript(script: Uint8Array): TxWeightEstimator {
this.outputCount++;
this.outputSize += 8 + getVarIntSize(script.length) + script.length;
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

@louisinger louisinger merged commit 633344b into arkade-os:master Jan 21, 2026
2 checks passed
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.

Wallet.sendOnchain should compute the tx size in order to estimate fees

4 participants