Skip to content

mempool: dust thresholds, the ephemeral rules, and the template floors Core applies (#661, #670) - #709

Open
bkeroack wants to merge 7 commits into
masterfrom
fix/661-670-dust-and-template-floors
Open

mempool: dust thresholds, the ephemeral rules, and the template floors Core applies (#661, #670)#709
bkeroack wants to merge 7 commits into
masterfrom
fix/661-670-dust-and-template-floors

Conversation

@bkeroack

@bkeroack bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #661. Closes #670.

Bottom of a four-PR stack; merge in order: this → #663#662#660-bounds.

Dust thresholds were wrong for every script type

Core's GetDustThreshold adds 67 vbytes to spend any witness program — 32 + 4 + 1 + 107/4 + 4 — and 148 for anything else, then rounds the fee up. satd added 68 for the three witness types it knew by name, 107 for P2SH, 68 for everything else, and truncated.

script satd Bitcoin Core
P2PKH 546 546
P2SH 417 540
P2WPKH 297 294
P2WSH / P2TR 333 330
P2A 333 240

The 330 row is the one that bites: Lightning anchor outputs are built against it, and a node that calls 330 dust refuses a commitment transaction its peers relay. The P2SH row is a 123-satoshi window in which satd relayed outputs Core drops.

The classification now branches on IsWitnessProgram as Core's does, so pay-to-anchor and future witness versions are priced as witness spends. Core's own table is pinned as golden vectors.

The dust rules were wrong too

Core splits them three ways: IsStandardTx permits one dust output (MAX_DUST_OUTPUTS_PER_TX), PreCheckEphemeralTx requires a dusty transaction to pay nothing at all, and CheckEphemeralSpends requires a child to sweep it. satd refused every dust output on the single-transaction path and enforced the zero-fee rule only inside submitpackage, with the strings written out by hand — MempoolError::EphemeralDustFee existed but was never constructed.

Both paths now share one pre_check_ephemeral, which reads the prioritisetransaction delta as well as the base fee (Core's base_fee != 0 || mod_fee != 0). It is gated on require_standard as Core gates it, and joins the exemptable standardness set an allow rule can forgive.

The two branches share the reject reason dust and differ only in the detail, so the tests assert the detail and the ephemeral fixtures move to a configuration with a real relay floor where the generic fallback says something else — the trap from #700.

Two standard output types satd did not relay

Bare P2PK — <pubkey> OP_CHECKSIG — was refused outright, and so were witness programs at versions satd has no predicate for (v2–v16 and pay-to-anchor), which defeats the point of the version space. Not "any witness program", though: a v0 program at an unrecognised length is NONSTANDARD to Core's Solver and stays refused. Bare multisig gains Core's x-of-3 bound.

-dustrelayfee reached only one of four dust decisions

prioritisetransaction, the package classifier and the stranded-parent unwind all took the built-in rate, so -dustrelayfee=0 changed the admission check and nothing else.

Two template floors

A spend of an immature coinbase can sit in the mempool after a reorg, and connect_block rejects the whole block for it. The assembler re-checks maturity for the height it is building, as Core's does.

-blockmintxfee was parsed, documented and restart-only — and read nowhere. It is applied now on the package feerate, as Core's addPackageTxs does, so a zero-fee parent still rides in on its child.

Its default changes from 1000 sat/kvB to Core's 1. The old value matched the default -minrelaytxfee, which is harmless only while the two agree: lower the relay floor and every transaction between them enters the mempool and is never mined. feature_rbf.py and feature_bip68_sequence.py both catch this, and a unit test fails if the default is put back.

Already fixed on master

For the record, since these issues predate them. #670's reorg sweep is Mempool::remove_for_reorg, landed in #657 with coinbase maturity, BIP 68, missing coins, locktime and the quarantine no-LeaveEvicted rule; pending_priority is gone. #661's child-side CheckEphemeralSpends runs on the single-transaction path since #700, and stranded parents are unwound. What is left of both issues is in this PR.

Verification

Three cargo gates green. Every guard perturbation-proved by a named failing test: the witness/legacy spend estimates, the IsWitnessProgram branch, div_ceil, the MAX_SCRIPT_SIZE half of IsUnspendable, the compact-size prefix, pre_check_ephemeral on the single-tx path, its modified-fee term, the at-most-one-dust rule, the configured dust rate in count_dust_outputs and in prioritisetransaction, bare P2PK, unknown witness versions, the multisig bound, template coinbase maturity, the -blockmintxfee floor, the package judgement, and the default floor.

Core-functional: mempool_dust.py flips to run (measured failing before, passing after), and mempool_spend_coinbase.py flips too — it was unblocked by the -25/-26 fix already on master and this is the measurement. The whole 37-row run set passes.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

Every `apt-get update` in CI runs under `set -euo pipefail`, so a failure
on *any* configured repository fails the step. The GitHub runner image
ships Google Chrome and Microsoft apt sources that none of satd's build
dependencies come from, and a hash-sum mismatch on the Chrome one took
down all five canary jobs — twice, including on a rerun.

Remove those sources before updating. The canaries still gate the PR on
exactly what they gated before; they just stop depending on a repository
they never install from.

Applied at all fourteen sites rather than only the canary's, because the
same step exists in CI, the Core-functional run, the differential fuzzer,
the e2e flake gate and the release workflow, and there is nothing
canary-specific about the failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
bkeroack and others added 5 commits September 9, 2026 16:27
`dust_threshold_with_rate` charged a 68-vbyte spend for witness outputs
where Core charges 67, a 107-vbyte spend for P2SH where Core charges 148,
and truncated a fee Core rounds up. Every threshold was wrong: P2WPKH 297
against Core's 294, P2TR 333 against 330 — the number Lightning anchor
outputs are built against — and P2SH 417 against 540, a 123-satoshi window
in which satd relayed outputs Core calls dust.

Branch on `IsWitnessProgram` as Core does, rather than on the three named
witness types, so an unknown witness version and pay-to-anchor are priced
as witness spends and a bare script is priced as a legacy one. Treat a
script longer than `MAX_SCRIPT_SIZE` as unspendable alongside `OP_RETURN`,
and size the length prefix. Core's own table is pinned as golden vectors.

The dust *rules* were wrong too. Core's `IsStandardTx` permits one dust
output — `MAX_DUST_OUTPUTS_PER_TX` — so that a zero-fee ephemeral-dust
parent stays relayable; the zero-fee requirement is `PreCheckEphemeralTx`
and the requirement that a child sweep it is `CheckEphemeralSpends`. satd
refused every dust output on the single-transaction path and enforced the
zero-fee rule only inside `accept_package`, with the strings written out
by hand. `MempoolError::EphemeralDustFee` existed but was never
constructed. Extract `pre_check_ephemeral` and call it from both paths;
it reads the `prioritisetransaction` delta as well as the base fee, as
Core's `base_fee != 0 || mod_fee != 0` does.

The two branches share the reject reason `dust` and differ only in the
detail, so the new tests assert the detail, and the ephemeral fixtures
move to a configuration with a real relay floor where the generic
fallback says something else.

`-dustrelayfee` now reaches every dust decision: `tx_has_dust_outputs`,
`count_dust_outputs` and `dust_output_indices` took the constant, so
`prioritisetransaction`, the package path and the stranded-parent unwind
ignored the operator's setting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
A spend of an immature coinbase can sit in the mempool after a reorg —
`remove_for_reorg` cannot cover one accepted between the reorg and the
sweep — and `connect_block` answers `bad-txns-premature-spend-of-coinbase`
for the whole block, so one such transaction costs the miner every fee in
the template. The assembler re-checks maturity against the height it is
building, alongside the BIP 68 re-check it already did.

`-blockmintxfee` was parsed, documented and restart-only, and read nowhere
in the node: the template floor did not exist. It is applied on the
*package* feerate, as Core's `addPackageTxs` compares
`chunk_feerate_vsize` against `blockMinFeeRate`, so a zero-fee parent
still rides in on a child that pays for both. At the default that
subsumes the ad-hoc "a zero-fee transaction needs a paying descendant"
rule it replaces, and `-blockmintxfee=0` now really does mine free
transactions.

The floor is a process value set once at startup rather than a parameter
on eight mining entry points, since the option is restart-only;
`create_template_with_floor` takes it explicitly so tests neither depend
on nor disturb process state.

Also documents `reorg` as a mempool eviction reason. The node has emitted
it on every carrier since the reorg sweep landed while the wire spec and
the Operator Manual published a shorter list; the wire-string test is now
exhaustive over the enum, so adding a reason without documenting it does
not compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
…w set

Core gates `PreCheckEphemeralTx` on `require_standard`, alongside
`IsStandardTx` (`validation.cpp`), so `-acceptnonstdtxn` lifts it. It also
belongs to the exemptable standardness set an `allow` rule can forgive
(§6.2): without that a policy written to relay a dust shape could no
longer relay one that pays a fee, which is every dust shape an operator
would write a rule for.

An earlier deferred failure still wins, because Core reports
`IsStandardTx`'s verdict first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
satd listed six output shapes by hand where Core accepts every type
`Solver` names. Two were missing.

Bare P2PK — `<pubkey> OP_CHECKSIG` — was refused outright, so a payment to
the output type of the earliest coinbases would not relay. Witness
programs at versions satd has no predicate for (v2 through v16, and
pay-to-anchor) were refused too, which defeats the point of the version
space: a node that will not relay them cannot relay the next soft fork's
transactions.

Not "any witness program", though: a v0 program at a length Core does not
recognise falls through `Solver` to NONSTANDARD, so v0 stays pinned to its
two sizes and only other versions are open.

Bare multisig gains Core's x-of-3 bound, which `Script::is_multisig` does
not check.

`testmempoolaccept` also runs the ephemeral-dust pre-check now. Core
reaches `PreCheckEphemeralTx` from `PreChecks`, which both RPCs go
through, and satd's separate `test_accept` had no equivalent — so it
answered `allowed: true` for a dusty fee-paying transaction
`sendrawtransaction` refuses.

Flips `mempool_dust.py` and `mempool_spend_coinbase.py` in the
Core-functional inventory. The second was unblocked by the -25/-26 fix
already on master; this is the measurement that confirms it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
… floor

satd's `-blockmintxfee` default was 1000 sat/kvB — the same value as the
default `-minrelaytxfee`. That is harmless only while the two agree.
Lower the relay floor, which Bitcoin Core's own functional tests do, and
every transaction between the two floors enters the mempool and is never
mined: `generate` stops draining the mempool at all, and
`feature_rbf.py` and `feature_bip68_sequence.py` both hang on it.

Core's `DEFAULT_BLOCK_MIN_TX_FEE` is 1 sat/kvB, three orders of magnitude
below the relay floor, and deliberately so: the floor exists to keep a
template from being padded with transactions that pay *nothing*, not to
second-guess relay policy.

Nothing depended on the old default, because the option was read nowhere
until the commit that applied it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
@bkeroack
bkeroack force-pushed the fix/661-670-dust-and-template-floors branch from 478d020 to 9dab8b4 Compare September 9, 2026 22:31
@bkeroack

bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased so that the CI fix from #715 is now the first commit on this branch (20484e67), and the rest of the stack sits on top of it. #715 is closed as folded in.

Why it had to move here rather than merge separately: a pull_request run is evaluated against the merge of the PR branch and its base, and for #710#721 that base is the branch below, not master. A fix living only on master therefore never reached them, which is why every canary on #709#713 was failing on E: Failed to fetch https://dl.google.com/linux/chrome-stable/deb/... Hash Sum mismatch — a repository none of the installed packages come from, failing the whole step under set -euo pipefail.

This branch's own content is unchanged: its tree differs from the previous tip by exactly the seven files in that commit, with the six workflow files byte-identical to the original.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

…ee rates

`blockmintxfee=0.00001` in a bitcoin.conf went through `.parse::<u64>().ok()`,
so Core's spelling of the option fell through to the default without a word —
on the one PR that makes the option live. It now resolves through the same
`file_fee_rate` path as minrelaytxfee/dustrelayfee/incrementalrelayfee: a
decimal is BTC/kvB, a bare integer is sat/kvB, and garbage stops the node.
The CLI arg gets the same value_parser. Manual row corrected (default is
1 sat/kvB, not 1000).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
@bkeroack

Copy link
Copy Markdown
Contributor Author

Independent review pass — one fix landed on this branch.

  • -blockmintxfee was parsed as a bare integer (sat/kvB) on the CLI while every other fee-rate option takes Core's BTC/kvB spelling, and the config-file key was not read at all. Both paths now go through the same fee-rate parser as minrelaytxfee; blockmintxfee=0.00001 resolves to 1 000 sat/kvB and a malformed value is a startup error rather than a silent default. Manual row and release notes updated.

Verified against Core v31.1 init.cpp / ParseMoney. Gates (clippy, full workspace suite, --features e2e check) and the Core-functional run set pass at the top of the stack.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant