Repository navigation
Conversation
Outpoint keys could be created more than once. Block undo then becomes
inexact or impossible, and nodes with different reorg histories
diverge.
connect_prevalidated writes every created output with an unchecked
utxos.put, and disconnect_tip deletes created outputs by key. Two paths
re-create a key:
- Zero-input transactions. With no inputs, a transaction needs no
authorization, and its txid is a pure function of its outputs, memo
and data. Once it has left the mempool, anyone can re-broadcast the
identical transaction and it is mined again, re-putting
utxos[Regular { txid, vout }]. Disconnecting the second block deletes
the output the first block still owns, so nodes that saw the reorg
split from nodes that did not on any later spend. Disconnecting the
first block afterwards fails with NoUtxo. A body carrying the same
zero-input transaction twice connects but can never be disconnected.
Re-mining after the output was spent resurrects it, and a later
disconnect of the spend fails with NoStxo.
- Coinbase outputs, with no zero-input transaction. Coinbase
{ merkle_root, vout } keys commit to H(coinbase, transactions) but not
to the parent block, so two blocks with identical bodies (for example
no transactions and the same zero-value coinbase output, allowed
because 0 <= fees 0) re-create the same keys.
Consensus rules added:
- State::validate_filled_transaction (mempool and block paths): none of
the transaction's Regular { txid, vout } output keys may already
exist in utxos or stxos (Error::OutPointAlreadyExists, "output
<outpoint> already exists", via State::ensure_outpoint_is_new).
- block::prevalidate and block::validate, right after the merkle root
check (validate_outpoint_uniqueness):
- a body may not contain the same txid twice
(Error::DuplicateTransaction);
- no coinbase output key may already exist in utxos or stxos
(Error::OutPointAlreadyExists).
Why these close every re-creation path. A block creates keys in exactly
two places: coinbase outputs as Coinbase { merkle_root, vout } and
transaction outputs as Regular { txid, vout }. filled_outputs maps
transaction.outputs one-to-one, and no tx data variant creates outputs
anywhere else, so the key check covers Bitcoin, withdrawal, BitAsset,
BitAsset control, BitAsset reservation, AMM LP token and Dutch auction
receipt outputs alike. (Deposit keys come from mainchain outpoints via
two_way_peg_data and are not touched here.)
- Across blocks: every key a block would create is checked against
utxos and stxos before the block applies. Once created, a key stays
in one of those two databases (spending moves it from utxos to stxos,
a failed withdrawal bundle moves it back) until the creating block is
disconnected. So a key that exists on the current chain can never be
written again, and a key freed by a disconnect may legitimately be
created again on the new branch.
- Within one body: prevalidate checks transactions against the
pre-block state, so two copies of one transaction would both pass the
key check. The body rule rejects a repeated txid. With unique txids,
Regular keys from different transactions differ, coinbase keys differ
by vout, and keys of different OutPoint variants never collide (the
borsh variant tag is the first key byte), so no body creates the same
key twice.
A zero-input transaction is still valid the first time it is mined;
only its replay is rejected. That keeps every honest feature working,
including the reserve_bitasset RPC, which builds a zero-input
reservation.
This is a consensus change: blocks that were previously valid (with a
duplicated txid or a re-created transaction or coinbase key) are now
invalid, so running networks need coordinated activation. Blocks that
contain a zero-input transaction mined once stay valid.
Cost: two LMDB contains_key lookups per transaction output and per
coinbase output, plus one txid sort per body.
Tests (lib/state/zero_input_replay_tests.rs, cargo test -p
plain_bitassets zero_input) drive validate_transaction,
prevalidate_block, apply_block and disconnect_tip, and compare exact
reject strings:
- T2: a zero-input tx is admitted and mined once; its replay is
rejected with OutPointAlreadyExists at mempool and at connect, both
with its output unspent and spent; nothing is written; undo is exact;
after disconnecting its block it can be mined again.
- T3/T3b: a signed tx whose output key is seeded in utxos / stxos.
- T4/T4b: an identical coinbase-only body re-creating an unspent /
spent coinbase key; exact undo.
- T5: the same txid twice in one body (zero-input and signed) gives
DuplicateTransaction.
- T6: money-path control (deposit spend round trip).
- T7a-g: the key check for BitAssetReservation, BitAssetMint,
BitAssetUpdate, AmmMint, AmmBurn, DutchAuctionCreate and
DutchAuctionCollect outputs.
With the two checks disabled, 13 of the 14 fail (T6 is the control).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Optional hardening on top of "Reject re-created outpoint keys". That commit already rejects every re-creation of an outpoint key; this one rejects zero-input transactions outright. Maintainers can take it or drop it independently. Consensus rule added in State::validate_filled_transaction (mempool and block paths), before the output-key check: a transaction must spend at least one input (Error::NoInputs, "transaction <txid> has no inputs"). What it adds over the output-key check: - Every txid commits to an outpoint that can be spent only once, so a transaction cannot be mined twice on one chain. That also makes the other txid-keyed state unique (BitAsset reservations in put_reservation, DutchAuctionId = txid), instead of relying on the transaction creating an output. - A zero-input, zero-output transaction creates no keys, so the output-key check cannot stop it being replayed for free without limit. This rule does. - A transaction without inputs carries no authorization, so anyone can broadcast it. With this rule every transaction is signed by the owner of at least one input. Consensus impact: stricter than the previous commit. A block containing any zero-input transaction is now invalid, including a reservation built by the old reserve_bitasset RPC (below). A chain that ever mined one fails initial block download under this rule, so running networks need a scan first, height-gated activation, or a fresh genesis. Companion wallet/RPC changes (non-consensus), so honest users are not affected: - rpc_server.rs reserve_bitasset built its transaction from Transaction::default() and Wallet::reserve_bitasset added only a reservation output, so the RPC produced a zero-input transaction (used by the vote integration test). It now starts from wallet.create_regular_transaction(Amount::ZERO), as the GUI does. - Wallet::select_bitcoins broke out of its loop before selecting any coin when value == 0, so every zero-value/zero-fee builder produced zero inputs: create_regular_transaction(0) (GUI reserve and register with fee 0), create_transfer(0, 0), create_withdrawal(0, 0, 0) and create_bitasset_transfer with fee 0, plus select_asset_utxos(Bitcoin, 0) in the AMM and Dutch auction builders. It now selects at least one non-zero Bitcoin UTXO and returns NotEnoughFunds if there is none. - create_bitasset_transfer and the GUI register_bitasset selected Bitcoin for a zero fee even though a BitAsset / reservation input already funds them. Both now select Bitcoin only when the fee is non-zero, so they keep working for a wallet without Bitcoin. create_bitasset_transfer returns NotEnoughFunds if it would end up with no inputs at all. The other RPC builders that start from Transaction::default() (amm_burn, amm_mint, amm_swap, dutch_auction_bid, dutch_auction_create, dutch_auction_collect, register_bitasset) always spend asset, receipt or reservation inputs. They produce no inputs only when asked for a zero amount of a BitAsset the wallet does not hold; the node then refuses the transaction with the NoInputs error. The GUI transaction creator builds whatever inputs the user picks and shows the same error. Tests: - lib/state/zero_input_replay_tests.rs: T1 rejects zero-input shapes (Bitcoin(0), with memo, Bitcoin(1), Withdrawal(0,0), zero outputs) at mempool validation, with a signed one-input control; T2 rejects a body containing a zero-input transaction, alone or after a valid spend, with nothing written; T8 rejects the exact shape the old reserve_bitasset RPC built, at mempool and at connect. T2 replaces the previous commit's replay test, since a zero-input transaction no longer connects even once. T5 still reports DuplicateTransaction before NoInputs. - lib/wallet.rs zero_value_builders_spend_an_input: zero-fee regular tx (and the reservation built on it), zero-value transfer and withdrawal each spend one input; zero-value-only wallets get NotEnoughFunds; a zero-fee BitAsset transfer spends only its BitAsset input. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mblowes
added a commit
to mbdrivechains/freebank
that referenced
this pull request
Sep 16, 2026
… and re-created outpoint keys A transaction must now spend at least one input; an output key that already exists in utxos or stxos may not be created again (regular and coinbase outputs); and a block body may not contain the same transaction twice. Without these rules a zero-input transaction could be mined more than once, after which block undo no longer matched block connect and a reorg could leave nodes with different UTXO sets. Includes the regression suite (lib/state/zero_input_replay_tests.rs). The same fix is offered upstream as LayerTwo-Labs/plain-bitassets#61. Consensus change: 0.3.1 cannot sync a chain started by 0.3.0 — fresh genesis. Also: RPC default port is now 6130 (6000 + slot 130), was 8454. Curated public snapshot of the private release commit 278d378609131c460e5db8a70bc7ab1c1fc37ec4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012U23VeHwgpqGJ8N1Ah3QM5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR stops an outpoint key from being created more than once. Today that can happen, and when it does, block
undo stops matching block connect. Nodes can then end up with different UTXO sets depending on which reorgs they
saw.
There are two commits:
be taken or dropped on its own.
Both commits are consensus changes. See "Deployment" below.
Problem
connect_prevalidatedwrites every created output with an uncheckedutxos.put, anddisconnect_tipdeletescreated outputs by key. That is only correct if no key is ever written twice on the same chain. Two things break
that assumption.
Zero-input transactions. A transaction with no inputs passes validation: it needs 0 authorizations for 0
inputs, and the same-body double-spend check only looks at input keys. Its txid depends only on its outputs, memo
and data. It commits to no spendable input, so once it has confirmed and left the mempool, the identical
transaction can be mined again. It carries no signature, so anyone can re-broadcast it. Re-mining it writes
utxos[Regular { txid, vout }]again, over the first copy. Then:no longer has the output. A node that did not see it still does. The two nodes disagree on any block that later
spends it.
NoUtxo, so the node cannot follow a reorg below it.utxos, and a later disconnect of the spendfails with
NoStxo.The
reserve_bitassetRPC currently builds exactly this kind of transaction: no inputs, one reservation output,zero fee.
Coinbase outputs. This case needs no zero-input transaction. Coinbase keys are
Coinbase { merkle_root, vout }, where the merkle root commits to the coinbase and transactions but not to theparent block. Two blocks with identical bodies therefore create the same coinbase keys. For example: two blocks
with no transactions and the same zero-value coinbase output, which is allowed because coinbase value 0 <= fees 0.
Impact
different UTXO set from nodes that did not.
NoUtxoorNoStxo.so the re-created outputs carry no Bitcoin value.
Commit 1: Reject re-created outpoint keys
New consensus rules:
State::validate_filled_transaction(mempool and block paths): none of the transaction'sRegular { txid, vout }output keys may already exist inutxosorstxos. Error:OutPointAlreadyExists,output <outpoint> already exists.block::prevalidateandblock::validate, right after the merkle root check:DuplicateTransaction,block body contains transaction <txid> more than once.utxosorstxos. Error:OutPointAlreadyExists.Why this is enough:
filled_outputsmapstransaction.outputsone-to-one, and noTxDatavariant creates outputs anywhere else. So the check coversBitcoin, withdrawal, BitAsset, BitAsset control, reservation, AMM LP token and Dutch auction receipt outputs
alike.
utxosorstxosuntil the block that created it is disconnected. Spending movesit to
stxos, and a failed withdrawal bundle moves it back. So a key that exists on the current chain cannotbe written again. A key freed by a disconnect can still be created again on the new branch.
stxoshalf of the check matters. Without it, re-mining a transaction whose output was already spent wouldbring that output back.
both pass the key check. The duplicate-txid rule covers that. With unique txids, no body creates the same key
twice: Regular keys differ by txid, coinbase keys differ by
vout, and keys of differentOutPointvariantsnever collide because the variant tag is the first key byte.
A zero-input transaction is still valid the first time it is mined. Only the replay is rejected. So this commit
changes no wallet, RPC or GUI behaviour, and the
reserve_bitassetRPC keeps working unchanged.Deposit keys come from mainchain outpoints via
two_way_peg_dataand are not touched.Cost: two LMDB
contains_keylookups per transaction output and per coinbase output, plus one txid sort perbody.
Commit 2: Require at least one input per transaction
New consensus rule in
State::validate_filled_transaction, before the output-key check: a transaction must spendat least one input. Error:
NoInputs,transaction <txid> has no inputs.What it adds on top of commit 1:
chain. That also makes the other txid-keyed state unique (reservations in
put_reservation, andDutchAuctionId = txid) without relying on the transaction having an output.rule does.
This rule rejects transactions the current wallet builds, so the commit also changes the wallet and RPC. These
changes are not consensus changes:
reserve_bitassetRPC: starts fromwallet.create_regular_transaction(Amount::ZERO)instead ofTransaction::default(), as the GUI already does. The reservation now spends a Bitcoin input and returns it aschange, so reserving needs a funded wallet.
Wallet::select_bitcoins: whenvalue == 0, it used to leave its loop before selecting any coin. It nowselects at least one non-zero Bitcoin UTXO, and returns
NotEnoughFundsif there is none. This affectscreate_regular_transaction(0),create_transferandcreate_withdrawalwith zero amounts, andselect_asset_utxos(Bitcoin, 0)in the AMM and Dutch auction builders. Those builders already return thesurplus as change.
create_bitasset_transferand the GUIregister_bitassetalready spend a BitAsset or reservation input, sothey now select Bitcoin only when the fee is non-zero. They keep working for a wallet that holds no Bitcoin.
create_bitasset_transferreturnsNotEnoughFundsif it would end up with no inputs.The other RPC builders that start from
Transaction::default()(amm_burn,amm_mint,amm_swap,dutch_auction_bid,dutch_auction_create,dutch_auction_collect,register_bitasset) always spend asset,receipt or reservation inputs. They only produce a zero-input transaction when asked for a zero amount of an asset
the wallet does not hold. The node then refuses it with
NoInputs.Why it is a separate commit: commit 1 closes the UTXO bug by itself and changes no user-facing behaviour. Commit 2
is a stricter rule with a wider consensus impact (see below) and needs wallet changes. Maintainers may prefer a
different wallet approach, or may not want the rule at all.
Tests
New file
lib/state/zero_input_replay_tests.rs. The tests drivevalidate_transaction,prevalidate_block,apply_blockanddisconnect_tipdirectly, using the existingfresh_stateandbitcoin_filled_outputhelpers. They compare the exact error string, so they fail if a rule is removed. They check that a rejected block
writes no state, and that connect followed by disconnect restores the exact prior state.
Bitcoin(0), with memo,Bitcoin(1),Withdrawal(0,0), no outputs) are rejected withNoInputsat mempool validation. A signed one-input transaction still validates.OutPointAlreadyExistsat mempool and at connect, with the output both unspent and spent. After its block is disconnected it can be mined again.NoInputsand writes nothing.utxos/stxos.DuplicateTransaction.BitAssetReservation,BitAssetMint,BitAssetUpdate,AmmMint,AmmBurn,DutchAuctionCreateandDutchAuctionCollectoutputs, at mempool and at connect.reserve_bitassetRPC built is rejected withNoInputs.Commit 2 also adds
wallet::test::zero_value_builders_spend_an_input. It checks that a zero-fee regulartransaction (and a reservation built on it), a zero-value transfer and a zero-value withdrawal each spend one
input; that a wallet with only zero-value UTXOs gets
NotEnoughFunds; and that a zero-fee BitAsset transferspends only its BitAsset input.
Results:
cargo test -p plain_bitassetscargo clippy --all-targets --all-featurescargo fmt --all -- --checkvote(usesreserve_bitassetover RPC)deposit_withdraw_roundtripinitial_block_downloadunknown_withdrawalWith commit 2's consensus rule but the old
reserve_bitassetRPC,votefails withtransaction <txid> has no inputs, which is why the wallet/RPC changes are part of commit 2.The integration tests were run against
plain_bitassets_appbuilt from this branch, with a regtestbitcoind/enforcer/electrs stack.
Deployment
This tightens consensus. Blocks that are valid today become invalid if they contain:
A network where some nodes run this and others do not can split on such a block. Options:
commit 2 this includes any zero-input transaction, for example a reservation made through the current
reserve_bitassetRPC. A chain that contains one will fail initial block download under commit 2.Related repositories
From reading older code, not running it, these appear to share the pattern: no input-count rule, an unchecked
utxos.putof created outputs on connect, and delete-or-NoUtxoon disconnect.thunder was not examined.
Found while building FreeBank on plain-bitassets.
🤖 Generated with Claude Code
https://claude.ai/code/session_012U23VeHwgpqGJ8N1Ah3QM5