One weight estimator for script types and size hints
A coin selector prices an output before anyone tells it what kind of output it is. A caller that tracks script types wants the witness discount to land in the fee; a caller holding nothing but a byte count still wants a number. So you write two fee calculators, one per description, and the two of them disagree by a byte at the edges. A byte at the edges is how you ship a transaction that will not relay.
I have been building custodial wallet services and on-chain payment systems since 2018, and fee estimation is the part of that work that rots quietly. It rots because the estimate lives in several places (a selection routine, a dust check, a candidate ranking) and each place rounds a little differently. In utxo-select (github.com/polycratia/utxo-select) I collapsed the two description paths into a single weight estimator. The mechanism that makes it possible fits in one sentence: a size hint gets priced as a legacy input or output stretched to that size.
Two descriptions of the same output#
A Utxo states what it will cost in one of two ways.
from utxo_select import ScriptType, Utxo
hinted = Utxo(txid="a" * 64, vout=0, value=200_000, confirmations=12)
typed = Utxo(
txid="b" * 64,
vout=0,
value=200_000,
confirmations=12,
script_type=ScriptType.P2WPKH,
)script_type names the script kind, and the input is priced by weight. script_size is the older hint: a virtual size for the input's signature script, defaulted to DEFAULT_INPUT_SCRIPT_SIZE, which is 107, the scriptSig of a legacy P2PKH input. A caller gets the hint without asking for anything, and that is the entire reason it stays supported. Target and the change description on SelectionRequest carry the same pair, with DEFAULT_OUTPUT_SCRIPT_SIZE of 25.
There is no third state. _check_script_type raises TypeError for anything that is neither a ScriptType nor None, and once a type is given the hint goes unread. The precedence sits in one place instead of getting argued about at every call site.
The arithmetic behind the hint is the standard bill: roughly 10 virtual bytes of transaction overhead, then 41 vbytes plus the scriptSig per input, then 9 vbytes plus the scriptPubKey per output. With the defaults of 107 and 25, an input comes to 148 vbytes and an output to 34. At fee_rate=12_000 (12 base units per vbyte, expressed per 1000 vbytes so that finer rates stay in integer arithmetic) that is 1_776 for every input and 408 for every output, whatever they hold.
The hint is a stretch, not a second model#
The whole of the translation is this:
def input_weight(script_type: ScriptType | None, script_size: int) -> int:
"""Weight one input adds, from its script type or from its size hint.
The hint is read only when no script type is given, and then as the
unlocking script of a legacy input stretched to it.
"""
if script_type is not None:
return script_type.input_weight
return (INPUT_OVERHEAD_VSIZE + script_size) * WITNESS_SCALE_FACTOR
def output_weight(script_type: ScriptType | None, script_size: int) -> int:
"""Weight one output adds, from its script type or from its size hint."""
if script_type is not None:
return script_type.output_weight
return (OUTPUT_OVERHEAD_VSIZE + script_size) * WITNESS_SCALE_FACTORThe hinted branch does not return a vsize. It returns weight, scaled by WITNESS_SCALE_FACTOR, because every byte of a legacy shape is non-witness data and so gets scaled in full. From the estimator's point of view there is one currency now.
The tables it meets on the other branch are written out the same way. An input's non-witness part is a 36-byte outpoint, the scriptSig with its length varint, and 4 bytes of sequence; the witness bytes are counted once:
_INPUT_WEIGHT: dict[ScriptType, int] = {
# scriptSig 1 + 72 signature + 1 + 33 pubkey, so 148 bytes in total.
ScriptType.P2PKH: 148 * WITNESS_SCALE_FACTOR,
# 41 bytes with an empty scriptSig; witness 1 + 1 + 72 + 1 + 33.
ScriptType.P2WPKH: 41 * WITNESS_SCALE_FACTOR + 108,
# 41 bytes with an empty scriptSig; witness 1 + 1 + 64 Schnorr signature.
ScriptType.P2TR: 41 * WITNESS_SCALE_FACTOR + 66,
}The two paths meet in one place, and at the defaults they agree exactly. A hinted input is (41 + 107) * 4, which is 592; a declared ScriptType.P2PKH input is 148 * 4, the same 592. On the output side (9 + 25) * 4 and 34 * 4 are both 136. I would rather not be grateful for that as a coincidence: it is a property worth a test, and it has one:
| Selection | vsize |
fee at 12_000 |
|---|---|---|
| Default hints, one input, one target, one change | 226 | 2_712 |
Same shape declared P2PKH throughout |
226 | 2_712 |
Same shape declared P2WPKH throughout |
141 | 1_692 |
The third row is what the hint cannot express at all. A size in vbytes has nowhere to put the witness discount.
Only the whole transaction knows whether it carries a witness#
Two more things reach the fee that no per-part number can see: a segwit transaction carries a marker and flag once, and a legacy input sitting alongside witness inputs still pays for an empty witness stack. Both belong to the assembled transaction, so both get decided in the estimator and nowhere else.
def estimate_shaped_weight(
inputs: Iterable[tuple[ScriptType | None, int]],
outputs: Iterable[tuple[ScriptType | None, int]],
) -> int:
weight = _OVERHEAD_WEIGHT
witness = False
legacy = 0
for script_type, script_size in inputs:
weight += input_weight(script_type, script_size)
if script_type is not None and script_type.is_segwit:
witness = True
else:
legacy += 1
for script_type, script_size in outputs:
weight += output_weight(script_type, script_size)
if witness:
weight += _MARKER_WEIGHT + legacy * _EMPTY_WITNESS_WEIGHT
return weightNote which side of the else the hinted input falls on. A hint is a legacy shape by construction, so it increments legacy and leaves witness alone: it gets billed for the empty witness stack if some other input brings a witness, and it cannot conjure a marker on its own. The stretch rides through as scaled weight, and the witness decision is made about the transaction the parts will form rather than about each part in isolation.
Mix one P2WPKH input with one legacy input paying two legacy outputs and the difference shows up in a single base unit. Price the parts apart (overhead 10, plus each input rounded to vbytes on its own, plus two outputs) and you get 294. Price them together: 40 + 272 + 592 + 272 weight, plus the marker and flag counted once and one empty witness stack, is 1_179, and -(-1179 // 4) is 295. Pricing the parts apart understates this transaction, because nothing in it charged for the marker. Three P2TR inputs paying a P2TR target with P2TR change go the other way: 270 apart, 269 together, because rounding each 230-weight input up to 58 vbytes charges three quarters of a byte each time for size that does not exist.
One rounding, at the end#
So the selector builds shapes and hands the whole set over once.
def _tx_vsize(
inputs: Sequence[Utxo], request: SelectionRequest, *, with_change: bool
) -> int:
"""Virtual size of the transaction these inputs and the request form.
The weight of every part is added up first and divided once, so a witness
input is not charged for the three quarters of a byte that rounding it on
its own would add.
"""
outputs = [
(target.script_type, target.script_size) for target in request.targets
]
if with_change:
outputs.append(
(request.change_script_type, request.change_script_size)
)
return weight_to_vsize(
estimate_shaped_weight(
[(utxo.script_type, utxo.script_size) for utxo in inputs], outputs
)
)weight_to_vsize is a ceiling division by four, -(-weight // WITNESS_SCALE_FACTOR), applied exactly once per transaction, which matches what consensus and relay policy do.
Where per-input rounding survives, and why that is safe#
One place still rounds a single input alone:
def _input_vsize(utxo: Utxo) -> int:
"""Marginal cost of one more input, rounded on its own."""
return weight_to_vsize(input_weight(utxo.script_type, utxo.script_size))
def _effective_value(utxo: Utxo, fee_rate: int) -> int:
"""What an output is worth once the fee for spending it is taken off."""
return utxo.value - estimate_fee(_input_vsize(utxo), fee_rate)That is the number deciding whether a candidate is worth spending at all and how candidates rank against each other. It is allowed to be slightly pessimistic, and the ScriptType.input_vsize docstring says so in as many words: rounding each input on its own overstates a transaction slightly, so it is "a safe marginal cost to compare candidates with, not a term to sum into a total".
That sentence is the rule I care about most in this module. The two roles look like the same quantity and are not. One is a ranking key, where erring high drops a marginal output instead of including an unaffordable one. The other is money a miner will take, where erring high by a byte per input adds up across a consolidating spend. Keeping them in separately named functions with that distinction written down is what stops someone (me, in six months) from summing the cheap one because it was already there...
What I would do differently#
The asymmetry in the public surface bothers me. estimate_weight is exported and accepts only ScriptType members, validated by _as_script_types. The function that actually prices a real mixed transaction, estimate_shaped_weight, is not in __all__. A caller who wants to measure a partially built transaction the way the selector does has to reach past the package's own front door. The typed-only helper is the convenient one and should stay, but the shaped one is the one that prices honestly, and it should probably be exported alongside it.
I would also reconsider pinning the hint to a legacy shape forever. Today it is the right default: the hint exists for callers who have a byte count and nothing else, and reading those bytes as non-witness data overcharges rather than undercharges, which is the correct direction for a fee estimate to fail. But a caller who knows the input is segwit and still only has a size gets billed out of the witness discount they are entitled to. If that case turns out to be common, the fix is not a second estimator: it is a hint that carries its own witness flag, so the stretch still rides through the one weight sum.
It comes down to one estimator that makes the witness decision once and divides by four once. Everything else in the library sits downstream of that, including the parts that are deliberately allowed to be wrong.