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.py — select_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.
Summary
BitcoinProvider.select_utxosestimates the BTC network fee assuming both transaction outputs are P2WPKH (31 vB each), via the hardcoded2 * 31term — regardless of what the destination or change scriptPubKey actually is: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 is8 (value) + 1 (len) + len(scriptPubKey):bc1q…)3…)1…)bc1q…, 32-byte)bc1p…)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); the2 * 31output term was left untouched, so the same "below-target fee rate" failure mode still exists on the output side.Where
allways/chain_providers/bitcoin.py—select_utxos(the twoest_vsizelines):The actual outputs are built in
send_amount_lightweight:Note also that the estimate always assumes two outputs, even when
change <= 546produces 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:11 + 1*68 + 2*31 = 141 vB11 + 1*68 + 43 (P2TR dest) + 31 (P2WPKH change) = 153 vB(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, computeout_vsize(script) = 8 + 1 + len(script.data)forto_scriptand (conditionally)my_script, mirroring how #459 madeinput_vsizeaddress-type-aware.