Skip to content

Reject re-created outpoint keys and zero-input transactions - #61

Open
mblowes wants to merge 2 commits into
LayerTwo-Labs:masterfrom
mblowes:fix-zero-input-replay
Open

mblowes wants to merge 2 commits into
LayerTwo-Labs:masterfrom
mblowes:fix-zero-input-replay

Conversation

@mblowes

@mblowes mblowes commented Sep 16, 2026

Copy link
Copy Markdown

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:

  1. Reject re-created outpoint keys. Closes every path that re-creates a key. This is the fix.
  2. Require at least one input per transaction. Optional hardening, plus the wallet/RPC changes it needs. It can
    be taken or dropped on its own.

Both commits are consensus changes. See "Deployment" below.

Problem

connect_prevalidated writes every created output with an unchecked utxos.put, and disconnect_tip deletes
created 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:

  • Disconnecting the second block deletes the output that the first block still owns. A node that saw that reorg
    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.
  • Disconnecting the first block afterwards fails with NoUtxo, so the node cannot follow a reorg below it.
  • If the output was already spent, re-mining brings it back into utxos, and a later disconnect of the spend
    fails with NoStxo.
  • A block body that contains the same zero-input transaction twice connects, but can never be disconnected.

The reserve_bitasset RPC 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 the
parent 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

  • A consensus split after a reorg: nodes that disconnected a block containing a re-created key end up with a
    different UTXO set from nodes that did not.
  • Nodes that cannot follow a reorg below such a block, because disconnect fails with NoUtxo or NoStxo.
  • We found no way to create or steal value this way. Zero-input transactions cannot pay out more than they spend,
    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's
    Regular { txid, vout } output keys may already exist in utxos or stxos. Error:
    OutPointAlreadyExists, output <outpoint> already exists.
  • block::prevalidate and block::validate, right after the merkle root check:
    • a body may not contain the same txid twice. Error: DuplicateTransaction,
      block body contains transaction <txid> more than once.
    • no coinbase output key may already exist in utxos or stxos. Error: OutPointAlreadyExists.

Why this is enough:

  • A block creates keys in exactly two places: coinbase outputs and transaction outputs. filled_outputs maps
    transaction.outputs one-to-one, and no TxData variant creates outputs anywhere else. So the check covers
    Bitcoin, withdrawal, BitAsset, BitAsset control, reservation, AMM LP token and Dutch auction receipt outputs
    alike.
  • Once created, a key stays in utxos or stxos until the block that created it is disconnected. Spending moves
    it to stxos, and a failed withdrawal bundle moves it back. So a key that exists on the current chain cannot
    be written again. A key freed by a disconnect can still be created again on the new branch.
  • The stxos half of the check matters. Without it, re-mining a transaction whose output was already spent would
    bring that output back.
  • Within one body, transactions are checked against the pre-block state, so two copies of one transaction would
    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 different OutPoint variants
    never 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_bitasset RPC keeps working unchanged.

Deposit keys come from mainchain outpoints via two_way_peg_data and are not touched.

Cost: two LMDB contains_key lookups per transaction output and per coinbase output, plus one txid sort per
body.

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 spend
at least one input. Error: NoInputs, transaction <txid> has no inputs.

What it adds on top of commit 1:

  • 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 (reservations in put_reservation, and
    DutchAuctionId = txid) without relying on the transaction having an output.
  • A zero-input, zero-output transaction creates no keys, so commit 1 cannot stop it being replayed for free. This
    rule does.
  • Every transaction is signed by the owner of at least one input.

This rule rejects transactions the current wallet builds, so the commit also changes the wallet and RPC. These
changes are not consensus changes:

  • reserve_bitasset RPC: starts from wallet.create_regular_transaction(Amount::ZERO) instead of
    Transaction::default(), as the GUI already does. The reservation now spends a Bitcoin input and returns it as
    change, so reserving needs a funded wallet.
  • Wallet::select_bitcoins: when value == 0, it used to leave its loop before selecting any coin. It now
    selects at least one non-zero Bitcoin UTXO, and returns NotEnoughFunds if there is none. This affects
    create_regular_transaction(0), create_transfer and create_withdrawal with zero amounts, and
    select_asset_utxos(Bitcoin, 0) in the AMM and Dutch auction builders. Those builders already return the
    surplus as change.
  • create_bitasset_transfer and the GUI register_bitasset already spend a BitAsset or reservation input, so
    they now select Bitcoin only when the fee is non-zero. They keep working for a wallet that holds no Bitcoin.
    create_bitasset_transfer returns NotEnoughFunds if 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 drive validate_transaction, prevalidate_block,
apply_block and disconnect_tip directly, using the existing fresh_state and bitcoin_filled_output
helpers. 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.

Test Commit What it checks
T1 2 Zero-input shapes (Bitcoin(0), with memo, Bitcoin(1), Withdrawal(0,0), no outputs) are rejected with NoInputs at mempool validation. A signed one-input transaction still validates.
T2 1 A zero-input transaction is mined once. Its replay is rejected with OutPointAlreadyExists at mempool and at connect, with the output both unspent and spent. After its block is disconnected it can be mined again.
T2 2 Replaces the commit 1 version: a body containing a zero-input transaction, alone or after a valid spend, is rejected with NoInputs and writes nothing.
T3 / T3b 1 A signed transaction whose output key is already in utxos / stxos.
T4 / T4b 1 An identical coinbase-only body that would re-create an unspent / spent coinbase key. Undo is exact.
T5 1 The same txid twice in one body, both zero-input and signed, gives DuplicateTransaction.
T6 1 Control on the normal money path: a deposit spend, then a spend of its output, then disconnecting both.
T7a–g 1 The output-key check for BitAssetReservation, BitAssetMint, BitAssetUpdate, AmmMint, AmmBurn, DutchAuctionCreate and DutchAuctionCollect outputs, at mempool and at connect.
T8 2 The exact transaction the old reserve_bitasset RPC built is rejected with NoInputs.

Commit 2 also adds wallet::test::zero_value_builders_spend_an_input. It checks that a zero-fee regular
transaction (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 transfer
spends only its BitAsset input.

Results:

Check After commit 1 After commit 2
cargo test -p plain_bitassets 34 passed, 0 failed 37 passed, 0 failed
cargo clippy --all-targets --all-features 0 warnings 0 warnings
cargo fmt --all -- --check pass pass
Integration test vote (uses reserve_bitasset over RPC) pass pass
Integration test deposit_withdraw_roundtrip pass pass
Integration test initial_block_download pass pass
Integration test unknown_withdrawal pass pass

With commit 2's consensus rule but the old reserve_bitasset RPC, vote fails with
transaction <txid> has no inputs, which is why the wallet/RPC changes are part of commit 2.

The integration tests were run against plain_bitassets_app built from this branch, with a regtest
bitcoind/enforcer/electrs stack.

Deployment

This tightens consensus. Blocks that are valid today become invalid if they contain:

  • with commit 1: the same txid twice, or a transaction or coinbase output whose key already exists;
  • with commit 2 as well: any zero-input transaction.

A network where some nodes run this and others do not can split on such a block. Options:

  • Coordinated upgrade. Before activating, scan existing chains for blocks that would now be rejected. With
    commit 2 this includes any zero-input transaction, for example a reservation made through the current
    reserve_bitasset RPC. A chain that contains one will fail initial block download under commit 2.
  • Height-gated activation. Apply the rules only from an activation height. This PR does not gate them.
  • Fresh genesis.

Related repositories

From reading older code, not running it, these appear to share the pattern: no input-count rule, an unchecked
utxos.put of created outputs on connect, and delete-or-NoUtxo on disconnect.

  • plain-bitnames
  • photon
  • coinshift

thunder was not examined.

Found while building FreeBank on plain-bitassets.

🤖 Generated with Claude Code

https://claude.ai/code/session_012U23VeHwgpqGJ8N1Ah3QM5

mblowes and others added 2 commits September 16, 2026 06:50
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
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.

1 participant