Skip to content

test, wallet: the fee-market acceptance suite, and a fee bump that spent confidential coins - #197

Open
albertodeluigi wants to merge 5 commits into
masterfrom
test/fee-market-suite
Open

albertodeluigi wants to merge 5 commits into
masterfrom
test/fee-market-suite

Conversation

@albertodeluigi

Copy link
Copy Markdown
Collaborator

Three commits, in the order they happened: a tool to make the fee market
observable, the standard suite that measures it, and the wallet bug that
suite found once a real wallet was pointed at it.

Why any of this exists

Every question worth asking about the open fee market has the same,
useless answer on an idle chain -- yes, at the relay floor -- because
while there is room in the next block nothing competes for it. A wallet,
a fee estimator or the block assembler can be badly wrong with the whole
test suite green. The fee market only exists when the next block is full.

contrib/sequentia/tx-spammer.py

Holds a chain at a chosen fee floor: it splits a wallet into independent
UTXOs and keeps the mempool a few blocks deep at a rate the operator
picks, on a closed loop against getmempoolcongestion. Standalone plain
JSON-RPC, because the node it has to drive is usually a server with the
daemon and not the source tree.

The budget, not the clock, ends a run -- fees go to whoever produces the
block, which on a shared testnet is somebody else's node. --plan prices
the run against the wallet that would pay for it and refuses a duration
it cannot afford, naming the rate that would have fitted.

run-local-testnet.py gains --blockmaxweight, --initialfreecoins and
--client-conf, the last of which writes the configuration of a
non-producing node -- the wallet under test -- which otherwise computes a
different genesis and fails to connect in a way that reads as a
networking fault.

test/functional/feature_any_asset_fee_market.py

Eleven cases sharing one wall, built by shrinking the block rather than
generating load: about 1 kvB of room, so three transactions fill a block
and a run takes under a minute. Capacity is measured from the suite's own
filler at runtime, and every exclusion is asserted against a block first
checked to be full -- a fee-market test that quietly stops congesting
passes for the rest of its life while measuring nothing.

An asset nobody prices; an under-bidder that waits and lands in the gap;
a transaction under the producer's own -blockmintxfee reserve, refused
even against a half-empty block; RBF; CPFP; and both again while
switching fee asset.

Two of them carry the claim the market rests on. Which assets a node
accepts fees in is POLICY, not consensus: producers with different price
servers may disagree about what a fee is worth, or whether it is a fee at
all, and must still build one chain. A regression there surfaces as a
fork rather than as a rejected transaction, so the suite drives the
disagreement all the way through -- one producer refuses the transaction
from its mempool, the other mines it, and the first accepts the block.

doc/sequentia/fee-market-testing.md explains the suite case by case and
says which cases a shared testnet can reproduce: everything about one
node's policy, nothing about disagreement between two.

wallet: a fee bump must not spend a confidential coin

Found by driving the desktop wallet against that regtest. Bumping the fee
into a different asset produced a replacement the wallet's own node
refused as bad-txns-in-ne-out -- after recording the bump and reporting
success. The user is left with a stuck transaction and a wallet that
believes it replaced it, and nothing in the interface says otherwise.

A bump builds an entirely explicit replacement and then leaves coin
selection free, where a confidential coin arrives as a CInputCoin with
its value and asset at zero: the wallet can fund the transaction with an
input the amount accounting cannot see. The same function already refuses
to bump a transaction whose OUTPUTS are blinded.
CCoinControl::m_only_explicit_inputs applies the invariant to the
inputs the bump fetches, and the failure path says the true thing rather
than "insufficient funds".

The wallet holding the fee asset both ways -- the ordinary state of a
wallet that has been used -- is enough to hit it, so this was never
confined to wallets whose funds are all confidential.

Verification

The suite is green over four consecutive runs, and case 10 fails without
the wallet change. Cases 1, 2, 4, 5, 7 and 9 were also walked through the
desktop GUI against a two-producer regtest, which is where the bug turned
up.

Every fee-market behaviour worth testing is invisible on an idle chain,
because on an idle chain the answer is always yes at the relay floor. A
wallet cannot be shown to lose an auction that nobody else is bidding
in, so "does my transaction get in", "does RBF lift it over the queue"
and "does CPFP rescue the parent" have no observable answer today
outside feature_any_asset_fee_congestion.py, which builds its wall by
hand inside one test.

tx-spammer.py manufactures the competition: it splits a wallet into a
working set of UTXOs and keeps the mempool holding a few blocks of
transactions that all pay a rate the operator chose, so the next block
is full and the price of entry is a known number rather than an
accident. It is standalone by design -- plain JSON-RPC, no test
framework import -- because the node it has to drive is usually a
server that has the daemon and not the source tree.

The budget, not the clock, is what ends a run, and that is the part
worth stating up front: fees are paid to whoever produces the block,
which on a shared testnet is somebody else's node, so the money is
gone. --plan prints the arithmetic and refuses a duration the wallet
cannot afford, naming the rate that would have fitted. At the block
sizes the live testnet runs, an hour of full blocks costs six million
vB times the rate, which is five figures of value at Bitcoin-mainnet
fee levels and a few thousand at ordinary ones. Discovering that from a
drained wallet halfway through a test is the failure this guards
against.

run-local-testnet.py gains the three options that make the free version
of the same experiment possible on one machine: --blockmaxweight to
shrink the block until a handful of transactions fills it,
--initialfreecoins so a wallet has something to bid with, and
--client-conf to write the consensus block out again for a
non-producing node -- the wallet under test -- which otherwise computes
a different genesis and fails to connect in a way that reads as a
networking fault. That node gets the same block ceiling as the
producers, since its own congestion projection is about the block it
would build.
Every question worth asking about the open fee market has the same,
useless answer on an idle chain -- yes, at the relay floor -- because
while there is room in the next block nothing competes for it. A wallet,
a fee estimator or the block assembler can therefore be badly wrong with
the whole suite green. feature_any_asset_fee_congestion.py showed the
ordering rules hold under congestion; this asks the operator's question
instead: does MY transaction confirm, and who decided.

Nine cases, in one file so they share one wall: an asset nobody prices;
an under-bidder that waits and lands in the gap; a transaction under the
producer's own -blockmintxfee reserve, which is refused even against a
half-empty block; RBF over the wall; CPFP; and both of those again while
switching fee asset, since value rather than atom count is what the
mempool compares.

Two of the nine are the reason the file exists. Which assets a node
accepts fees in is POLICY: producers with different price servers may
disagree about what a fee is worth, or whether it is a fee at all, and
must still build one chain. A regression there surfaces as a fork rather
than as a rejected transaction, so the suite drives the disagreement all
the way through -- producer A refuses the transaction from its mempool,
producer B mines it, and A's best block becomes B's block.

The wall is made by shrinking the block rather than by generating load:
-blockmaxweight=8000 leaves about 1 kvB, so three transactions fill a
block and a run takes under a minute. The capacity is measured from the
suite's own filler at runtime rather than hard-coded, and every exclusion
is asserted against a block that was checked to be full first -- a
fee-market test that quietly stops congesting is one that passes for the
rest of its life while measuring nothing.

Three traps cost a false result each while writing it, and the harness
now closes them, because the next fee test will meet them too. Coin
selection left to itself spends the wallet's own unconfirmed change, so
a filler becomes the CHILD of the transaction under test and drags it in
as an accidental CPFP -- the suite passed or failed by coin selection.
A blinded input, which setup change is on a custom chain, forces the
whole transaction to be blinded and fails as "output has nonce, but is
not blinded", a confidentiality error wearing a fee error's clothes. And
change has to be change: an output paid to a receive address is a
payment, so bumpfee cannot shrink it and refuses.

doc/sequentia/fee-market-testing.md explains the suite case by case,
records those traps, and says which cases a shared testnet can
reproduce -- everything about one node's policy, nothing about
disagreement between two, which needs a private network or a partner.
Driving the desktop wallet against this suite's own regtest turned up a
failure the suite cannot see, and fixing the harness to look for it
turned up a second one it had been surviving by luck.

The tenth case is the shape that broke: an RBF that switches fee asset,
where the new asset has to come from an input of its own and the wallet
holds confidential outputs beside explicit ones. It passes, and it stays
as the regression guard for that shape -- but it does NOT reproduce the
real failure, and says so. A test that passes for the wrong reason is
worse than no test, so the measured evidence goes in the documentation
instead: bumping across assets produced a transaction the wallet's own
node refused as bad-txns-in-ne-out, and decoding the pair shows the fee
asset's side failing to balance around an input that is blinded. A bump
rebuilds every output explicitly and then leaves coin selection free,
where a confidential coin arrives with its value and asset at zero --
so it can pick an input the accounting cannot see. feebumper already
refuses a transaction whose OUTPUTS are blinded; the same invariant is
not applied to the inputs it goes and fetches. Reproducing it needs an
output that is genuinely blinded, which ignoreblindfail quietly prevents
here, so the fix is left until it can be verified rather than guessed.

The flake is mine. take_confirmed handed out outputs from a cached list,
and bumpfee picks its own inputs: one of those can be an entry still
sitting in the cache, so a later case builds on an already-spent input
and dies as bad-txns-inputs-missingorspent -- in whichever case happens
to draw it. Four clean runs had said "deterministic" and were wrong
about it. The list is now asked for fresh each time, which listunspent
answers authoritatively because it already excludes what the mempool
spends.
Bumping the fee into a different asset produced a replacement the
wallet's own node refused as bad-txns-in-ne-out -- and it did so AFTER
recording the bump and reporting success, which leaves the user with a
stuck transaction and a wallet that believes it replaced it. Nothing in
the interface says anything is wrong. It was found by driving the
desktop wallet against the fee-market suite's own regtest.

CreateRateBumpTransaction builds an entirely explicit replacement: it
rebuilds every recipient without a blinding key and asks for the new fee
asset's change with add_blinding_key = false. It then sets
fAllowOtherInputs and leaves coin selection free. A confidential coin
reaches selection as a CInputCoin whose effective_value, value and asset
are all left at zero, so the wallet can fund the transaction with an
input the explicit amount accounting cannot see, and the sums do not
match. The same function already refuses to bump a transaction whose
OUTPUTS are blinded; the invariant was simply never applied to the
inputs the bump goes and fetches.

CCoinControl::m_only_explicit_inputs, honoured in AvailableCoins and set
by the bumper, closes that. A bump now either funds itself explicitly or
fails, and can no longer emit a transaction that cannot be relayed.

Failing has to say the true thing. "Insufficient funds" is a lie to
someone looking at the balance in their own wallet, so when the fee
asset is present but held only in confidential outputs the bumper says
that instead, and names the way out: bump in another asset, or unblind
the balance first.

Two functional cases cover it. The first is the one that matters: a
wallet holding the fee asset BOTH ways -- the ordinary state of a wallet
that has been used -- must take the explicit coin, and every input of the
replacement is checked. Without this change that case fails, so the bug
was never confined to wallets whose funds are all confidential.

Two things had to be got right for the cases to test anything at all,
and both had already produced a green test of nothing. Blinding does not
happen merely because the destination is confidential: with
ignoreblindfail on, a transaction that cannot balance its blinding drops
it silently and pays explicitly, and a single confidential output can
never balance -- so the funding uses two, and then asserts the coins are
really blinded. And filler paid to the suite's own wallet left 0.001
coins behind, one of which is worth exactly the fee a bump first
estimates: branch-and-bound takes it as an exact match, the transaction
then grows by an input and a change output, and the bump dies as "Could
not cover fee" in whichever case happens to draw it. Filler is now paid
out of the wallet.

That last one exposed a separate weakness left alone here: a cross-asset
bump whose added input makes the transaction larger than the estimate it
selected against does not retry. It fails loudly rather than corrupting
anything, so it is recorded in the documentation rather than fixed in
the same change.
Executable bit on the two new scripts, which both carry a shebang;
encoding="utf8" on the one open() that lacked it; and "trough of a wave"
reworded, because codespell reads it as a misspelling of "through" and a
warning nobody can act on is worse than a plainer word.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants