Skip to content

[Bug] select_utxos fee estimation hardcodes both outputs as P2WPKH (31 vB) — Taproot/P2WSH/P2PKH destinations underpay the fee and can miss the swap window #487

Description

@jaso0n0818

Summary

BitcoinProvider.select_utxos estimates the BTC network fee assuming both transaction outputs are P2WPKH (31 vB each), via the hardcoded 2 * 31 term — regardless of what the destination or change scriptPubKey actually is:

est_vsize = 11 + len(selected) * input_vsize + 2 * 31

But the lightweight send path builds the destination output from an arbitrary user-supplied address (to_script = address_to_scriptpubkey(to_address)), and the change output from the miner's own script (my_script). A standard output's vsize is 8 (value) + 1 (len) + len(scriptPubKey):

Output type scriptPubKey output vB
P2WPKH (bc1q…) 22 31 (assumed)
P2SH (3…) 23 32
P2PKH (1…) 25 34
P2WSH (bc1q…, 32-byte) 34 43
P2TR / Taproot (bc1p…) 34 43

So whenever the destination (or the change) is not P2WPKH, the fee is under-estimated. The most common real case today is a Taproot (bc1p…) recipient: the destination output is 43 vB but is counted as 31 vB — a 12 vB shortfall per send. (P2PKH dest under-counts by 3 vB, P2SH by 1 vB.)

This is the output-side analogue of the input-sizing bug fixed in #459 (which corrected only input_vsize); the 2 * 31 output term was left untouched, so the same "below-target fee rate" failure mode still exists on the output side.

Where

allways/chain_providers/bitcoin.pyselect_utxos (the two est_vsize lines):

def select_utxos(self, utxos, amount, is_segwit, fee_rate_override=None):
    fee_rate = self.estimate_fee_rate(override=fee_rate_override)
    input_vsize = 68 if is_segwit else 148
    ...
        est_vsize = 11 + len(selected) * input_vsize + 2 * 31   # <-- both outputs assumed P2WPKH
        fee = est_vsize * fee_rate
        if total_in >= amount + fee:
            break

    est_vsize = 11 + len(selected) * input_vsize + 2 * 31       # <-- same assumption
    fee = est_vsize * fee_rate

The actual outputs are built in send_amount_lightweight:

to_script = address_to_scriptpubkey(to_address)          # destination: ANY type (incl. P2TR / P2WSH)
tx.vout.append(TransactionOutput(amount, to_script))
if change > 546:                                         # change output only when > dust
    tx.vout.append(TransactionOutput(change, my_script))

Note also that the estimate always assumes two outputs, even when change <= 546 produces a single-output tx — so the change-less case is over-estimated, while the non-P2WPKH-destination case is under-estimated.

Reproduction

A miner whose committed address is native segwit (is_segwit=True, input_vsize=68) fulfilling a swap to a Taproot recipient, 1 input, with change:

  • Estimated vsize: 11 + 1*68 + 2*31 = 141 vB
  • Actual vsize: 11 + 1*68 + 43 (P2TR dest) + 31 (P2WPKH change) = 153 vB
  • Shortfall: 12 vB. At 30 sat/vB the tx pays ~4230 sat for a 153 vB tx → effective 27.6 sat/vB, i.e. ~8% under the targeted rate. Under fee pressure the dest tx confirms slower than targeted and can miss the swap reservation window.

(With multiple non-P2WPKH-sized outputs the gap compounds; a P2PKH-change miner paying a Taproot recipient under-counts by 12 + 3 = 15 vB.)

Impact

Same failure mode #459 was filed for, on the output side: the destination payout pays below the targeted fee rate, so under mempool fee pressure it confirms slower than intended and risks missing the swap reservation/confirmation window — a fund-loss / failed-fulfillment path for the miner, not just cosmetic. Taproot receive addresses are now common, so this fires on ordinary swaps, not edge cases.

Suggested fix

Size each output by its real scriptPubKey instead of assuming 2 * 31, and only count the change output when it is actually created (change > 546). For example, compute out_vsize(script) = 8 + 1 + len(script.data) for to_script and (conditionally) my_script, mirroring how #459 made input_vsize address-type-aware.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions