pyrxd.fee_sizing — What fee a signed transaction must pay¶
The single implementation of the trial/final fee-sizing rule used by every
builder that signs twice. Distinct from pyrxd.fee_models — Fee estimation, which models a
transaction’s size from its shape for Transaction.fee(); this module
works from the measured serialized length of an already-signed transaction
and is what stands between a build and a min relay fee not met rejection
that Radiant cannot fee-bump.
The one place pyrxd decides how many photons a signed transaction must pay.
Why this module exists¶
Every builder in this SDK that pays a miner fee follows the same two-pass shape:
build a trial transaction, sign it, measure its bytes, size the fee from that
measurement, then rebuild and re-sign the final transaction. The trap is that
the two passes sign different messages — the final one commits to the real
output values — so their ECDSA signatures are not the same length. A DER signature
here is 69, 70 or 71 bytes depending on how many leading zero bytes r and s
carry (measured over 3000 distinct messages: 17 / 1457 / 1526). A fee sized purely
off the trial pass therefore lands below the rate it was built for whenever the
final signature is the longer one.
Signing is deterministic (RFC 6979: same key, same message, same signature — verified, 20 signatures over one message are one distinct value), so this is not a flaky one-in-three. It is a property of the individual transaction: a given set of inputs, recipient and amount either underpays or does not, every time. Retrying does not help. Roughly a third of distinct sends fall on the wrong side of it.
Measured on RxdWallet.build_send_tx before the fix, 2000 builds per shape at
10_000 photons/byte: 25.4% short at one input, 31.8% at two, 33.7% at three,
36.8% at five (worst observed shortfall 4 bytes’ worth of fee). build_send_max_tx
was the same, 26.2%-38.1%; HdWallet’s copies of the same two builders,
23.6%-33.2%.
At the default rate that shortfall is fatal rather than cosmetic.
RADIANT_EFFECTIVE_MIN_RELAY_PHOTONS_PER_KB is exactly the mainnet relay
floor, so one byte short is 66: min relay fee not met — and Radiant has
neither RBF nor CPFP (Radiant-Core src/validation.cpp:667/:866 reject
any mempool conflict; src/miner.cpp:404 selects on the transaction’s own
GetModifiedFeeRate()), so the transaction cannot be bumped or replaced. It sits
on its own inputs until DEFAULT_MEMPOOL_EXPIRY — 8 hours — before a rebuild is
even possible.
This module exists so that rule has ONE implementation. It previously had three
(glyph/ft.py, cli/swap_book_cmds.py, and — absent, which was the bug —
wallet.py and hd/wallet.py), and this repo has a measured history of the
same rule drifting apart across copies.
Not to be confused with pyrxd.fee_models¶
pyrxd.fee_models.SatoshisPerKilobyte is a FeeModel
for Transaction.fee(): it models a transaction’s size from its shape
(counting inputs, outputs and varints) so a fee can be estimated before signing.
This module works the other way round — from the measured serialized length of
an already-signed transaction — and is what decides whether a build is allowed
to be returned at all. Estimating a size and proving a signed transaction pays
for its own bytes are different jobs; only the second one can fail closed.
The two floors are different things¶
The caller’s rate.
fee_ratephotons/byte, chosen by whoever built the transaction. Always binding: returning a transaction that does not pay the rate it was built for is a broken builder regardless of chain.The protocol relay floor. What
AcceptToMemoryPooldemands (nModifiedFees < GetEffectiveMinRelayFee(height).GetFee(nSize), sized againsttx.GetTotalSize()— the full serialized size, there is no vsize on this chain). It is chain policy, not the caller’s choice.
They coincide at the default: 10_000_000 photons/kB is 10_000 per byte
exactly. They part company when a caller deliberately picks a lower rate, which
is legitimate on regtest or a chain they control — a default regtest node relays at
a tenth of the mainnet floor. required_fee() therefore treats a sub-floor
fee_rate as that deliberate opt-out and binds only the caller’s rate, rather
than silently multiplying a regtest fee by ten. Callers whose rate arrives from an
untrusted or unvalidated source (a config file, an RPC reading) want
fee_never_below_relay_floor() instead, which has no opt-out.
- pyrxd.fee_sizing.MAX_FEE_OVERPAY_MULTIPLE: int = 10¶
How many times the fee requirement a single input may exceed before it is treated as a mistake rather than a choice.
10x leaves generous headroom for a deadline-critical spend and still catches a whole-wallet UTXO by three orders of magnitude. It is a MULTIPLE, not an absolute: a genuinely large requirement scales with it.
What crossing it means is deliberately NOT uniform, and that is the whole design. The cold-recovery CLI REFUSES above it (an operator is present,
--allow-overpayis one flag away, and nothing is racing). The builders inpyrxd.gravity.htlc_spendonly WARN, because refusing a claim that the node would have accepted hands the asset to the counterparty’s CSV refund — strictly worse than overpaying a fee (docs/threat-model.mdS21) — and because a legitimate deadline-racing spend can carry more than 10x headroom on purpose.
- pyrxd.fee_sizing.assert_fee_rate_clears_relay_floor(fee_rate, *, what, allow_below_relay_floor=False, allow_overpay=False, error_type=<class 'ValueError'>)[source]¶
Judge a per-byte fee rate from BOTH ends. Returns the rate.
The one implementation of “is this rate even viable”, shared by every builder that takes a
fee_ratefrom a caller. It exists becauserequired_fee()does not do this (see its docstring): a builder that validates onlyfee_rate > 0and then sizes withrequired_fee()will happily return a transaction 10_000x under the mainnet floor, and every guard downstream of it will agree the transaction is correct — because it is, at the rate it was asked for. The rate is the thing that has to be judged, and it can only be judged here, before any bytes exist.Too low. Radiant has neither RBF nor CPFP, so a sub-floor transaction cannot be bumped by any means: it squats on its own inputs until mempool expiry, 8 hours later. That makes a sub-floor rate a fund-safety bug rather than a tuning mistake, which is why this refuses instead of warning.
Too high — the half this gate did not used to have. A fee is
size × fee_rate, so a ratektimes the floor pays exactlyktimes the requirement:fee_rate / floorandfee_overpay_multiple()are the same number, which is why the bound here isMAX_FEE_OVERPAY_MULTIPLErather than a second constant invented for the purpose. Every builder behind this gate spends the overpay irreversibly, and an NFT transfer and a sweep have no change output at all, so the entire difference leaves with the miner. Measured onbuild_nft_transfer_txbefore this bound existed:fee_rate=10_000_000— which is literallyRADIANT_EFFECTIVE_MIN_RELAY_PHOTONS_PER_KB, the per-kB constant this module exports one import away from the per-byte one — burned 2.32-2.33 BILLION photons (23.2-23.3 RXD) off a 229-230 byte transfer, silently, with the build reporting success. That is a 1000-1004x overpay against the same transaction’s floor-rate fee. The figures are ranges because the fee tracks the DER signature length, which is 71 or 72 bytes run to run; re-measured over 40 builds on 2026-08-12, the fee was 2_320_000_000 or 2_330_000_000, never a single value. (An earlier note here quoted a single “2,320,000,000 … at a 1009x overpay”; the magnitude is right, the precision was not, and 1009x did not reproduce.)The ceiling is
MAX_FEE_OVERPAY_MULTIPLE × relay_floor_photons_per_byte()= 100_000 photons/byte: 1.0 RXD/kB against a chain whose floor is 0.10 and which has no mempool competition to bid against. The highest deliberate rate anywhere in this repository is 90_000 (9x), measured over everyfee_rate=literal insrc/andtests/, so the bound has room — and it is a MULTIPLE, so it tracks the floor if the floor moves.Stated rather than left to be discovered: one extra zero on a rate that is already the floor lands on exactly 100_000, which this permits. The bound catches the 100x and 1000x slips — the per-kB/per-byte confusion above all — not every fat finger.
allow_below_relay_flooris the deliberate, greppable escape hatch — named the same way asallow_below_protocol_floor— for regtest and for chains the caller controls, which legitimately relay lower.allow_overpayis its mirror, for a caller who means an unusually high rate. Each skips only its own bound: the rate still has to be a positive int, and opting out of one never opts out of the other.Which override each public builder exposes (re-derived by reading the signatures, because the universal claim that once sat here was false in one direction and nobody had checked): both, everywhere —
RxdWallet.__init__,HdWallet.build_send_tx/build_send_max_tx/send/send_max,WatchOnlyTxBuilder.build_send,FtUtxoSet.build_transfer_tx/build_airdrop_tx(and theirGlyphBuilderwrappers viaFtTransferParams/FtAirdropParams), andGlyphBuilder.build_nft_transfer_txviaTransferParams.That symmetry is new (#458). Until then no glyph builder accepted
allow_below_relay_floor, so a transfer at a regtest rate of1_000raised with no way through — while a MINT at the same rate on the same chain succeeded, becauseGlyphMinterhad the opt-out.relay_floor_photons_per_byte()is a fixed mainnet constant, so that refusal was the guard rejecting work that was valid on the caller’s own chain, not a chain rule being enforced.The two ends still are not equivalent, and the difference is why each opt-out is spelled separately rather than one flag covering both. Above the ceiling the overpay is already gone by the time anything downstream could notice — an NFT transfer and a sweep have no change output — so the build-time refusal is the only place the loss can be prevented. Below the floor nothing is spent: the refusal costs a re-run, while the override lets a builder emit a transaction the network will not relay, which on Radiant cannot be RBF’d or CPFP’d and squats its own inputs until mempool expiry 8 hours later. That is why this module calls a sub-floor rate a fund-safety bug rather than a tuning mistake, and why each override skips only its own bound: passing
allow_below_relay_floormust never quietly widen the ceiling.Why this is not the same bound as
max_urgency_multiplier, which is validated only>= 1.0. They act on different things and compose rather than compete. This one bounds a rate the caller passes in, in photons per BYTE, where the failure being caught is a unit slip — the per-kB constant handed to a per-byte parameter — which the caller did not intend and cannot see. The urgency multiplier is not an input rate at all: it scales a fee already bound below byprotocol_floor_per_kb, it is an explicitly named policy field whose value IS the caller’s statement of intent (the same statementallow_overpay=Truemakes here), andfee_overpay_ceiling()deliberately takesmax(floor, target)so that a raised urgency target RAISES the overpay ceiling with it — a legitimately urgent fee must not read as a mistake. Bounding the multiplier would therefore cap a deliberate deadline-racing spend, which is the one place this repository has decided (docs/threat-model.mdS21) that overpaying beats being refused.
- pyrxd.fee_sizing.assert_pays_for_its_size(*, size_bytes, fee_paid, fee_rate, what, error_type=<class 'ValueError'>)[source]¶
Fail closed unless a SIGNED transaction pays for the bytes it actually contains.
Call this on the final transaction, after the last
Transaction.sign(), with its measured serialized length — never on the trial pass, which is the mistake this whole module exists to prevent.error_typeexists only so each caller keeps the exception class its own API already documents (glyph.ftraisesValueError,walletraisesValidationError); the check and the message are identical either way.- Returns:
the required fee, when it is covered.
- Raises:
error_type – when it is not. Raising costs an aborted build; returning instead costs the inputs for 8 hours, because the result cannot be fee-bumped on Radiant by any means.
- Parameters:
- Return type:
- pyrxd.fee_sizing.assert_tx_pays_for_itself(tx, fee_rate, *, what, error_type=<class 'ValueError'>)[source]¶
assert_pays_for_its_size()sourced from the transaction itself.Uses the transaction’s own serialized length and its own
total_value_in - total_value_out, so the number checked is the number the node will compute and the numberget_fee()reports to the caller. Requires every input to carry asource_transaction.
- pyrxd.fee_sizing.bitcoin_virtual_size(*, stripped_size, total_size)[source]¶
Bytes Bitcoin charges the relay floor against: BIP141
vsize.vsize = ceil(weight / 4)whereweight = stripped_size * 3 + total_size(BIP141; Bitcoin CoreGetTransactionWeight/GetVirtualTransactionSize).stripped_size— the serialization without marker, flag and witness (the bytes the txid is hashed over).total_size— the full serialization with them, the bytes that go on the wire.
For a non-witness transaction the two are equal and
vsize == total_size.Caveat, stated rather than silently assumed: Bitcoin Core’s mempool actually uses
max(weight, nSigOpCost * nBytesPerSigOp * 4) / 4, so a transaction with an unusually high sigop-to-byte ratio is charged more than this returns. For the single-input P2WPKH / P2SH-P2WPKH / P2TR shapes this SDK builds, weight dominates by an order of magnitude and the two agree; a builder for sigop-dense scripts would need the fuller form.
- pyrxd.fee_sizing.fee_for_kb_rate(size_bytes, per_kb)[source]¶
ceil(size_bytes × per_kb / 1000)— a node’s own fee derivation, rounded UP.CFeeRate::GetFeetruncates (ceil=false,Radiant-Coresrc/feerate.cpp:95). Rounding up instead makes this at most one photon stricter than the node — deliberately, because being one photon short is a broadcast that cannot be taken back.Integer-only: floats would introduce drift in exactly the last photon that matters.
- pyrxd.fee_sizing.fee_never_below_relay_floor(size_bytes, fee_rate)[source]¶
max(size × fee_rate, protocol floor)with no opt-out.For callers whose rate crosses a trust boundary — a config file, an operator flag, an RPC reading — where “the caller meant it” is not a safe assumption.
- pyrxd.fee_sizing.fee_overpay_ceiling(*, floor, target)[source]¶
The largest fee an operator can plausibly have MEANT, given this requirement.
max(floor, target) xMAX_FEE_OVERPAY_MULTIPLE.flooris what the node demands;targetis the deadline-aware pool-sizing figure, which can be higher. Taking the max means urgency raises the ceiling with it rather than making a legitimately urgent fee look like a mistake.
- pyrxd.fee_sizing.fee_overpay_multiple(fee_photons, *, floor, target)[source]¶
How many times the fee requirement this input actually pays (>= 1.0 is normal).
- pyrxd.fee_sizing.min_relay_fee(size_bytes)[source]¶
The protocol relay floor for a
size_bytestransaction, in photons.
- pyrxd.fee_sizing.radiant_relay_size(raw_tx)[source]¶
Bytes Radiant charges the relay floor against:
tx.GetTotalSize().AcceptToMemoryPoolcompares againstGetEffectiveMinRelayFee(height).GetFee(nSize)withnSize = tx.GetTotalSize()— the full serialized size, carrying an explicit “Do not change this to use virtualsize without coordinating a network policy upgrade” (Radiant-Coresrc/validation.cpp:774). Radiant has no segwit, so total size is the only size there is; this function exists to name the rule, not to compute anything.Pass the signed transaction: a DER signature is 69-71 bytes run to run, so a size taken before signing is an estimate, and an estimate one byte short is a fee below the floor.
- pyrxd.fee_sizing.relay_floor_photons_per_byte()[source]¶
Radiant’s effective relay floor expressed per BYTE.
Derived from
RADIANT_EFFECTIVE_MIN_RELAY_PHOTONS_PER_KBrather than written out, so a change to the floor moves every caller at once.- Return type:
- pyrxd.fee_sizing.required_fee(size_bytes, fee_rate)[source]¶
Photons a
size_bytestransaction must pay atfee_ratephotons/byte.This binds the caller’s rate and NOTHING ELSE. Read that literally before relying on it — an earlier version of this docstring said it “binds BOTH floors”, and that claim was false in the only direction that matters.
The
maxbelow cannot raise anything at the current constants.RADIANT_EFFECTIVE_MIN_RELAY_PHOTONS_PER_KBis 10_000_000, an exact multiple of 1000, somin_relay_fee(size) == size * 10_000with no rounding — and the branch is only reached whenfee_rate >= 10_000, wheresize * fee_rateis already>=that. Measured: 0 differences from ``size * fee_rate`` over 200_000 random (size, rate) pairs, and 0 over an exhaustive sweep ofsize1..2999 against every rate within 3 of the floor. Themaxis kept because it stops being dead the moment the per-kB constant is not a multiple of 1000 (fee_for_kb_raterounds up whilerelay_floor_photons_per_byterounds down), not because it is doing work today.So the protocol floor is enforced here only by the caller having chosen a
fee_rateat or above it. A sub-floor rate is passed straight through, on the reading that it is a deliberate opt-out (regtest, or a chain the caller controls) — see the module docstring for why raising it instead would make every node-level proof of this code vacuous.That opt-out is only safe where
fee_rateis trusted. It is a HOLE anywhere the rate can arrive unvalidated:required_fee(226, 1)is 226 photons against a mainnet requirement of 2_260_000, a factor of 10_000, and every downstream assertion built on this function agrees the result is fine. Callers must therefore gate the RATE themselves —assert_fee_rate_clears_relay_floor()at the entry point, orfee_never_below_relay_floor()in place of this function — rather than expecting this to catch it.pyrxd.walletandpyrxd.hd.walletdo the former;pyrxd.cli.swap_book_cmdsdoes the latter.