From bf351102ba28ee1ab91ad14632fc706b19c96d54 Mon Sep 17 00:00:00 2001 From: mblowes Date: Wed, 16 Sep 2026 06:50:03 +1000 Subject: [PATCH 1/2] Reject re-created outpoint keys 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 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) --- lib/state/block.rs | 46 +- lib/state/error.rs | 4 + lib/state/mod.rs | 53 ++ lib/state/zero_input_replay_tests.rs | 833 +++++++++++++++++++++++++++ 4 files changed, 934 insertions(+), 2 deletions(-) create mode 100644 lib/state/zero_input_replay_tests.rs diff --git a/lib/state/block.rs b/lib/state/block.rs index 2c378591..45fb71f5 100644 --- a/lib/state/block.rs +++ b/lib/state/block.rs @@ -8,8 +8,8 @@ use crate::{ types::{ AmountOverflowError, Authorization, BitAssetId, Body, FilledOutput, FilledOutputContent, GetAddress as _, GetBitcoinValue as _, Header, - InPoint, OutPoint, OutPointKey, OutputContent, SpentOutput, TxData, - Verify as _, + InPoint, MerkleRoot, OutPoint, OutPointKey, OutputContent, SpentOutput, + TxData, Txid, Verify as _, }, }; @@ -18,6 +18,44 @@ fn calculate_total_inputs(body: &Body) -> usize { body.transactions.iter().map(|t| t.inputs.len()).sum() } +/// Check that a block body does not create any outpoint key twice, and that +/// its coinbase outpoints do not already exist. Must run after the merkle root +/// check, against the pre-block state. +/// +/// * A body may not contain the same txid twice. With unique txids, every +/// `Regular { txid, vout }` key created by the body is distinct, coinbase +/// keys differ by `vout`, and keys of different `OutPoint` variants never +/// collide, so no body creates the same key twice. Transaction output keys +/// are checked against existing state per transaction in +/// `State::validate_filled_transaction`. +/// * Coinbase outpoint keys (`Coinbase { merkle_root, vout }`) must not +/// already exist in `utxos` or `stxos`. The merkle root commits only to +/// `(coinbase, transactions)`, not to the parent block, so two blocks with +/// identical bodies (e.g. no transactions and the same zero-value coinbase +/// output, which is allowed since coinbase value 0 <= fees 0) would +/// otherwise re-create the same coinbase keys. +fn validate_outpoint_uniqueness( + state: &State, + rotxn: &RoTxn, + merkle_root: MerkleRoot, + body: &Body, +) -> Result<(), Error> { + let mut txids: Vec = + body.transactions.iter().map(|tx| tx.txid()).collect(); + txids.par_sort_unstable(); + if let Some(dup) = txids.windows(2).find(|w| w[0] == w[1]) { + return Err(Error::DuplicateTransaction { txid: dup[0] }); + } + for vout in 0..body.coinbase.len() { + let outpoint = OutPoint::Coinbase { + merkle_root, + vout: vout as u32, + }; + let () = state.ensure_outpoint_is_new(rotxn, &outpoint)?; + } + Ok(()) +} + /// Validate a block, returning fees pub fn validate( state: &State, @@ -48,6 +86,7 @@ pub fn validate( }; return Err(err); } + let () = validate_outpoint_uniqueness(state, rotxn, merkle_root, body)?; let mut coinbase_value = bitcoin::Amount::ZERO; for output in &body.coinbase { coinbase_value = coinbase_value @@ -128,6 +167,9 @@ pub fn prevalidate( return Err(err); } + let () = + validate_outpoint_uniqueness(state, rotxn, computed_merkle_root, body)?; + let mut coinbase_value = bitcoin::Amount::ZERO; for output in &body.coinbase { coinbase_value = coinbase_value diff --git a/lib/state/error.rs b/lib/state/error.rs index 02f4b64c..61be445a 100644 --- a/lib/state/error.rs +++ b/lib/state/error.rs @@ -366,6 +366,8 @@ pub enum Error { Db(Box), #[error(transparent)] DutchAuction(#[from] DutchAuction), + #[error("block body contains transaction {txid} more than once")] + DuplicateTransaction { txid: Txid }, #[error(transparent)] FillTxOutputContents(#[from] FillTxOutputContents), #[error( @@ -397,6 +399,8 @@ pub enum Error { SpendWithdrawalOutput { outpoint: OutPoint }, #[error("Withdrawal bundle event block doesn't exist")] NoWithdrawalBundleEventBlock, + #[error("output {outpoint} already exists")] + OutPointAlreadyExists { outpoint: OutPoint }, #[error(transparent)] PendingWithdrawalBundleUnknown(#[from] PendingWithdrawalBundleUnknown), #[error( diff --git a/lib/state/mod.rs b/lib/state/mod.rs index 679dc55f..beacccb6 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -27,6 +27,8 @@ mod dutch_auction; pub mod error; mod rollback; mod two_way_peg_data; +#[cfg(test)] +mod zero_input_replay_tests; pub use amm::{AmmPair, PoolState as AmmPoolState}; pub use bitassets::SeqId as BitAssetSeqId; @@ -603,12 +605,63 @@ impl State { } } + /// Check that an outpoint that is about to be created does not already + /// exist, either unspent (`utxos`) or spent (`stxos`). + /// + /// Block connection writes created outputs with an unchecked `put`, and + /// `disconnect_tip` deletes created outputs by key and restores spent + /// outputs from `stxos`. If an outpoint key could be created a second + /// time, disconnecting either creating block would delete or fail to find + /// the other's output, so the UTXO set would depend on reorg history + /// (`NoUtxo` / `NoStxo` on a later disconnect, and nodes that saw a + /// different reorg history would diverge on a later spend). + /// Keys of different `OutPoint` variants never collide. + pub(crate) fn ensure_outpoint_is_new( + &self, + rotxn: &RoTxn, + outpoint: &OutPoint, + ) -> Result<(), Error> { + let key = OutPointKey::from_outpoint(outpoint); + if self.utxos.contains_key(rotxn, &key)? + || self.stxos.contains_key(rotxn, &key)? + { + return Err(Error::OutPointAlreadyExists { + outpoint: *outpoint, + }); + } + Ok(()) + } + /// Validates a filled transaction, and returns the fee pub fn validate_filled_transaction( &self, rotxn: &RoTxn, tx: &FilledTransaction, ) -> Result { + // The outpoints created by this transaction must not already exist + // (see `ensure_outpoint_is_new`). A transaction's txid is a pure + // function of its contents, so an identical transaction can be mined + // again once it has left the mempool if it has no inputs (it carries + // no authorization, so anyone can re-broadcast it), which would + // re-create its `Regular { txid, vout }` outpoints. A transaction that + // spends inputs cannot be mined again (its inputs are gone), and this + // check rejects the replay either way. + // + // `FilledTransaction::filled_outputs` maps `transaction.outputs` + // one-to-one and `connect_prevalidated` keys every filled output as + // `Regular { txid, vout }`, so this covers outputs of every content + // kind, independent of `tx.data`: Bitcoin, withdrawal, BitAsset, + // BitAsset control, BitAsset reservation, AMM LP token and Dutch + // auction receipt outputs. This runs on the mempool path and, via + // `block::prevalidate`, against the pre-block state on connect. + let txid = tx.txid(); + for vout in 0..tx.transaction.outputs.len() { + let outpoint = OutPoint::Regular { + txid, + vout: vout as u32, + }; + let () = self.ensure_outpoint_is_new(rotxn, &outpoint)?; + } let () = self.validate_reservations(tx)?; let () = self.validate_bitassets(rotxn, tx)?; let fee = tx.bitcoin_fee()?; diff --git a/lib/state/zero_input_replay_tests.rs b/lib/state/zero_input_replay_tests.rs new file mode 100644 index 00000000..f1daf467 --- /dev/null +++ b/lib/state/zero_input_replay_tests.rs @@ -0,0 +1,833 @@ +//! Regression tests: zero-input transactions and outpoint-key re-creation. +//! +//! ## The defect +//! +//! There was no input-count rule: a transaction with NO inputs passed +//! `State::validate_transaction` (0 authorizations == 0 inputs) and +//! `block::prevalidate` (the same-body double-spend check only looks at input +//! keys). Its txid is a pure function of its contents, so the identical tx +//! could be mined again later, and `block::connect_prevalidated` re-`put` +//! `utxos[Regular { txid, vout }]` without checking for an existing row, while +//! `block::disconnect_tip` deletes by key and errors `NoUtxo` on a missing +//! row. With Z = {inputs: [], outputs: [Bitcoin(0)]}: Z validated; Z connected +//! at h1 and again at h2; disconnecting h2 deleted the output still owned by +//! h1 (a node that saw h2 then diverges from one that did not on any later +//! spend of it); disconnecting h1 afterwards failed `NoUtxo`; a body carrying +//! Z twice connected but could never be disconnected; a spent (Z, 0) was +//! resurrected by re-mining Z. A zero-input tx carries no authorization, so +//! anyone can re-broadcast it. +//! +//! The same re-creation exists WITHOUT zero-input txs for COINBASE keys: +//! `Coinbase { merkle_root, vout }` with `merkle_root = H(coinbase, txs)` (no +//! parent commitment), so two blocks with no transactions and the same +//! zero-value coinbase output (coinbase value 0 <= fees 0) re-create the same +//! key (T4/T4b below). +//! +//! ## The fix (consensus change) +//! +//! - `State::validate_filled_transaction`: a tx output key may not already +//! exist in `utxos` or `stxos` -> `Error::OutPointAlreadyExists`. This is +//! independent of output content and tx data, so it covers BitAsset, AMM and +//! Dutch auction outputs as well (T7). +//! - `block::prevalidate` (and `block::validate`): no txid twice in one body +//! -> `Error::DuplicateTransaction`; coinbase output keys may not already +//! exist in `utxos` or `stxos` -> `Error::OutPointAlreadyExists`. +//! +//! A zero-input transaction is still valid the first time it is mined; only +//! its replay is rejected (T2). +//! +//! `validate_filled_transaction` is shared by the mempool path +//! (`validate_transaction`: `submit_transaction`, peer `handle_push_tx`, the +//! miner's `get_transactions`) and the block path (`apply_block` = +//! `prevalidate` + `connect_prevalidated`). +//! +//! Every test drives the live functions and asserts the exact reject reason. +//! Reasons are compared as strings so the suite also compiles against the +//! unfixed code (where it fails). + +use std::collections::HashMap; + +use bitcoin::hashes::Hash as _; +use ed25519_dalek::SigningKey; + +use crate::{ + authorization, + state::{ + Error, State, + test::{bitcoin_filled_output, fresh_state}, + }, + types::{ + Address, AssetId, AuthorizedTransaction, BitAssetDataUpdates, + BitAssetId, BlockHash, Body, DutchAuctionParams, FilledOutput, Header, + InPoint, OutPoint, OutPointKey, Output, OutputContent, SpentOutput, + Transaction, TxData, Txid, Update, VerifyingKey, + }, +}; + +const ADDR_A: Address = Address([0xAA; 20]); + +// --------------------------------------------------------------------------- +// Exact reject reasons. +// --------------------------------------------------------------------------- + +fn outpoint_exists_reason(outpoint: OutPoint) -> String { + format!("output {outpoint} already exists") +} + +fn duplicate_tx_reason(txid: Txid) -> String { + format!("block body contains transaction {txid} more than once") +} + +/// Require `res` to be an error whose `Display` is exactly `expected`. +fn expect_reject( + ctx: &str, + res: Result, + expected: &str, +) -> anyhow::Result<()> { + match res { + Err(err) if err.to_string() == expected => Ok(()), + Err(err) => anyhow::bail!( + "{ctx}: wrong reject reason\n got: {err}\n expected: {expected}" + ), + Ok(ok) => anyhow::bail!( + "{ctx}: accepted ({ok:?}), expected rejection: {expected}" + ), + } +} + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +/// Z = {inputs: [], outputs: [Bitcoin(sats) -> A]} +fn zero_input_tx(sats: u64) -> Transaction { + Transaction::new( + Vec::new(), + vec![bitcoin_filled_output(ADDR_A, sats).into()], + ) +} + +fn unauthorized(tx: Transaction) -> AuthorizedTransaction { + AuthorizedTransaction { + transaction: tx, + authorizations: Vec::new(), + } +} + +fn make_block( + prev_side_hash: Option, + txs: Vec, + coinbase: Vec, +) -> (Header, Body) { + let body = Body::new(txs, coinbase); + let header = Header { + merkle_root: Body::compute_merkle_root( + &body.coinbase, + &body.transactions, + ), + prev_side_hash, + prev_main_hash: bitcoin::BlockHash::from_byte_array([0; 32]), + }; + (header, body) +} + +/// Apply via the live path; commit only on success (a failed rwtxn is +/// dropped = aborted, exactly as the node does). +fn apply( + env: &sneed::Env, + state: &State, + header: &Header, + body: &Body, +) -> Result<(), Error> { + let mut rwtxn = env.write_txn().expect("write txn"); + state.apply_block(&mut rwtxn, header, body)?; + rwtxn.commit().expect("commit"); + Ok(()) +} + +fn disconnect( + env: &sneed::Env, + state: &State, + header: &Header, + body: &Body, +) -> Result<(), Error> { + let mut rwtxn = env.write_txn().expect("write txn"); + state.disconnect_tip(&mut rwtxn, header, body)?; + rwtxn.commit().expect("commit"); + Ok(()) +} + +#[derive(Debug, PartialEq)] +struct Snapshot { + tip: Option, + height: Option, + utxos: HashMap, + stxos: HashMap, +} + +fn snapshot(env: &sneed::Env, state: &State) -> Snapshot { + use fallible_iterator::FallibleIterator as _; + let rotxn = env.read_txn().expect("read txn"); + let stxos = state + .stxos + .iter(&rotxn) + .expect("stxos iter") + .map(|(key, spent): (OutPointKey, SpentOutput)| { + Ok((key.to_outpoint(), spent)) + }) + .collect() + .expect("stxos collect"); + Snapshot { + tip: state.try_get_tip(&rotxn).expect("tip"), + height: state.try_get_height(&rotxn).expect("height"), + utxos: state.get_utxos(&rotxn).expect("utxos"), + stxos, + } +} + +fn validate( + env: &sneed::Env, + state: &State, + tx: &AuthorizedTransaction, +) -> Result { + let rotxn = env.read_txn().expect("read txn"); + state.validate_transaction(&rotxn, tx) +} + +/// A key we control and its address. +fn owner(seed: u8) -> (SigningKey, Address) { + let signing_key = SigningKey::from_bytes(&[seed; 32]); + let vk: VerifyingKey = signing_key.verifying_key().into(); + let address = authorization::get_address(&vk); + (signing_key, address) +} + +/// Fund `address` with a deposit-origin (money-path) Bitcoin UTXO, written +/// directly the way `two_way_peg_data::connect` does. +fn fund_deposit( + env: &sneed::Env, + state: &State, + address: Address, + tag: u8, + sats: u64, +) -> OutPoint { + let outpoint = OutPoint::Deposit(bitcoin::OutPoint { + txid: bitcoin::Txid::from_byte_array([tag; 32]), + vout: 0, + }); + let mut rwtxn = env.write_txn().expect("write txn"); + state + .utxos + .put( + &mut rwtxn, + &OutPointKey::from_outpoint(&outpoint), + &bitcoin_filled_output(address, sats), + ) + .expect("put"); + rwtxn.commit().expect("commit"); + outpoint +} + +// --------------------------------------------------------------------------- +// T2 — replay of a zero-input tx. Z is admitted and mined once; mining it +// again is rejected by the output-key check whether (Z, 0) is unspent or +// spent, nothing is written, and undo stays exact. +// --------------------------------------------------------------------------- + +#[test] +fn zero_input_t2_zero_input_tx_replay_rejected() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t2")?; + let empty = snapshot(&env, &state); + let (sk, addr) = owner(5); + let z = unauthorized(Transaction::new( + Vec::new(), + vec![bitcoin_filled_output(addr, 0).into()], + )); + let zp = OutPoint::Regular { + txid: z.transaction.txid(), + vout: 0, + }; + + // Z validates and connects the first time. + let res = validate(&env, &state, &z); + anyhow::ensure!( + matches!(res, Ok(fee) if fee == bitcoin::Amount::ZERO), + "first Z must validate: {res:?}" + ); + let g = make_block(None, vec![z.clone()], Vec::new()); + apply(&env, &state, &g.0, &g.1)?; + let after_g = snapshot(&env, &state); + anyhow::ensure!(after_g.utxos.contains_key(&zp)); + let expected = outpoint_exists_reason(zp); + + // (a) Replay while (Z, 0) is unspent. + expect_reject( + "T2a validate_transaction(Z again, (Z,0) unspent)", + validate(&env, &state, &z), + &expected, + )?; + let h1 = make_block(Some(g.0.hash()), vec![z.clone()], Vec::new()); + expect_reject( + "T2a apply_block({Z} again, (Z,0) unspent)", + apply(&env, &state, &h1.0, &h1.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == after_g, + "rejected block wrote state" + ); + + // (b) Spend (Z, 0), then replay Z: the key is now in `stxos`, and + // re-creating it would resurrect the spent output. + let s = authorization::authorize( + &[(addr, &sk)], + Transaction::new(vec![zp], vec![bitcoin_filled_output(addr, 0).into()]), + )?; + let h1 = make_block(Some(g.0.hash()), vec![s], Vec::new()); + apply(&env, &state, &h1.0, &h1.1)?; + let spent = snapshot(&env, &state); + anyhow::ensure!( + !spent.utxos.contains_key(&zp) && spent.stxos.contains_key(&zp) + ); + expect_reject( + "T2b validate_transaction(Z again, (Z,0) spent)", + validate(&env, &state, &z), + &expected, + )?; + let h2 = make_block(Some(h1.0.hash()), vec![z.clone()], Vec::new()); + expect_reject( + "T2b apply_block({Z} again, (Z,0) spent)", + apply(&env, &state, &h2.0, &h2.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == spent, + "rejected block wrote state" + ); + + // Undo is exact, and once g is disconnected (Z, 0) no longer exists, so Z + // can be mined again on the new branch. + disconnect(&env, &state, &h1.0, &h1.1)?; + anyhow::ensure!(snapshot(&env, &state) == after_g, "undo not exact"); + disconnect(&env, &state, &g.0, &g.1)?; + anyhow::ensure!(snapshot(&env, &state) == empty, "undo not exact"); + apply(&env, &state, &g.0, &g.1)?; + anyhow::ensure!(snapshot(&env, &state) == after_g); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T3 — the output-key check for a signed transaction. An honest transaction +// that spends inputs cannot create an existing key, so the pre-existing row is +// seeded directly. +// --------------------------------------------------------------------------- + +type T3Setup = ( + temp_dir::TempDir, + sneed::Env, + State, + AuthorizedTransaction, + OutPoint, +); + +fn t3_setup(name: &str) -> anyhow::Result { + let (tmp, env, state) = fresh_state(name)?; + let g = make_block(None, Vec::new(), Vec::new()); + apply(&env, &state, &g.0, &g.1)?; + let (sk, addr) = owner(9); + let dep = fund_deposit(&env, &state, addr, 3, 1000); + let t = authorization::authorize( + &[(addr, &sk)], + Transaction::new( + vec![dep], + vec![bitcoin_filled_output(addr, 1000).into()], + ), + )?; + let created = OutPoint::Regular { + txid: t.transaction.txid(), + vout: 0, + }; + Ok((tmp, env, state, t, created)) +} + +#[test] +fn zero_input_t3_tx_output_key_already_unspent_rejected() -> anyhow::Result<()> +{ + let (_tmp, env, state, t, created) = t3_setup("zero_input_t3")?; + anyhow::ensure!(validate(&env, &state, &t).is_ok(), "control before seed"); + { + let mut rwtxn = env.write_txn()?; + state.utxos.put( + &mut rwtxn, + &OutPointKey::from_outpoint(&created), + &bitcoin_filled_output(ADDR_A, 0), + )?; + rwtxn.commit()?; + } + let expected = outpoint_exists_reason(created); + expect_reject( + "T3 validate_transaction(T, (T,0) already in utxos)", + validate(&env, &state, &t), + &expected, + )?; + let pre = snapshot(&env, &state); + let tip = pre.tip; + let b = make_block(tip, vec![t], vec![]); + expect_reject( + "T3 apply_block({T}, (T,0) already in utxos)", + apply(&env, &state, &b.0, &b.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "rejected block wrote state" + ); + Ok(()) +} + +#[test] +fn zero_input_t3b_tx_output_key_already_spent_rejected() -> anyhow::Result<()> { + let (_tmp, env, state, t, created) = t3_setup("zero_input_t3b")?; + { + let mut rwtxn = env.write_txn()?; + state.stxos.put( + &mut rwtxn, + &OutPointKey::from_outpoint(&created), + &SpentOutput { + output: bitcoin_filled_output(ADDR_A, 0), + inpoint: InPoint::Regular { + txid: Txid([0x55; 32]), + vin: 0, + }, + }, + )?; + rwtxn.commit()?; + } + let expected = outpoint_exists_reason(created); + expect_reject( + "T3b validate_transaction(T, (T,0) already in stxos)", + validate(&env, &state, &t), + &expected, + )?; + let pre = snapshot(&env, &state); + let b = make_block(pre.tip, vec![t], vec![]); + expect_reject( + "T3b apply_block({T}, (T,0) already in stxos)", + apply(&env, &state, &b.0, &b.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "rejected block wrote state" + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T4 — coinbase keys: re-creation without any zero-input transaction. +// --------------------------------------------------------------------------- + +/// g = {coinbase: [Bitcoin(0) -> A], txs: []}; h1 = an identical body on top of +/// g has the same merkle root, hence the same `Coinbase{root,0}` key. +#[test] +fn zero_input_t4_coinbase_key_recreation_rejected() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t4")?; + let empty = snapshot(&env, &state); + let coinbase = vec![Output::from(bitcoin_filled_output(ADDR_A, 0))]; + + let g = make_block(None, Vec::new(), coinbase.clone()); + apply(&env, &state, &g.0, &g.1)?; + let after_g = snapshot(&env, &state); + let cb = OutPoint::Coinbase { + merkle_root: g.0.merkle_root, + vout: 0, + }; + anyhow::ensure!(after_g.utxos.contains_key(&cb)); + + let h1 = make_block(Some(g.0.hash()), Vec::new(), coinbase); + anyhow::ensure!(h1.0.merkle_root == g.0.merkle_root); + { + let rotxn = env.read_txn()?; + expect_reject( + "T4 prevalidate_block(h1, identical coinbase-only body)", + state + .prevalidate_block(&rotxn, &h1.0, &h1.1) + .map(|p| p.next_height), + &outpoint_exists_reason(cb), + )?; + } + expect_reject( + "T4 apply_block(h1)", + apply(&env, &state, &h1.0, &h1.1), + &outpoint_exists_reason(cb), + )?; + anyhow::ensure!(snapshot(&env, &state) == after_g); + + // Undo stays exact: disconnecting g returns to the empty state. + disconnect(&env, &state, &g.0, &g.1)?; + anyhow::ensure!(snapshot(&env, &state) == empty, "undo not byte-exact"); + Ok(()) +} + +/// The spent variant (formerly: resurrection + spend replay): a spent +/// coinbase key (row in `stxos`) cannot be re-created either, and the +/// connect^3 / disconnect^2 round trip is byte-exact. +#[test] +fn zero_input_t4b_spent_coinbase_key_recreation_rejected() -> anyhow::Result<()> +{ + let (_tmp, env, state) = fresh_state("zero_input_t4b")?; + let empty = snapshot(&env, &state); + let (sk, addr) = owner(42); + let coinbase = vec![Output::from(bitcoin_filled_output(addr, 0))]; + + let g = make_block(None, Vec::new(), coinbase.clone()); + apply(&env, &state, &g.0, &g.1)?; + let cb = OutPoint::Coinbase { + merkle_root: g.0.merkle_root, + vout: 0, + }; + let s = authorization::authorize( + &[(addr, &sk)], + Transaction::new(vec![cb], vec![bitcoin_filled_output(addr, 0).into()]), + )?; + let h1 = make_block(Some(g.0.hash()), vec![s.clone()], Vec::new()); + apply(&env, &state, &h1.0, &h1.1)?; + let spent = snapshot(&env, &state); + anyhow::ensure!( + !spent.utxos.contains_key(&cb) && spent.stxos.contains_key(&cb) + ); + + let h2 = make_block(Some(h1.0.hash()), Vec::new(), coinbase); + anyhow::ensure!(h2.0.merkle_root == g.0.merkle_root); + expect_reject( + "T4b apply_block(h2 re-creates spent coinbase key)", + apply(&env, &state, &h2.0, &h2.1), + &outpoint_exists_reason(cb), + )?; + anyhow::ensure!(snapshot(&env, &state) == spent); + + // The spend S cannot replay either (its input is gone). + let res = validate(&env, &state, &s); + anyhow::ensure!(matches!(res, Err(Error::NoUtxo(_))), "{res:?}"); + + disconnect(&env, &state, &h1.0, &h1.1)?; + disconnect(&env, &state, &g.0, &g.1)?; + anyhow::ensure!(snapshot(&env, &state) == empty, "undo not byte-exact"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T5 — body level: the same txid twice in ONE body. +// --------------------------------------------------------------------------- + +#[test] +fn zero_input_t5_duplicate_txid_in_one_body_rejected() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t5")?; + let g = make_block(None, Vec::new(), Vec::new()); + apply(&env, &state, &g.0, &g.1)?; + + // (a) Z twice. Transaction checks run against the pre-block state, so + // without the body rule both copies would pass the output-key check. + let z = zero_input_tx(0); + let pre = snapshot(&env, &state); + let b = make_block( + Some(g.0.hash()), + vec![unauthorized(z.clone()), unauthorized(z.clone())], + vec![], + ); + { + let rotxn = env.read_txn()?; + expect_reject( + "T5a prevalidate_block(body with Z twice)", + state + .prevalidate_block(&rotxn, &b.0, &b.1) + .map(|p| p.next_height), + &duplicate_tx_reason(z.txid()), + )?; + } + expect_reject( + "T5a apply_block(body with Z twice)", + apply(&env, &state, &b.0, &b.1), + &duplicate_tx_reason(z.txid()), + )?; + anyhow::ensure!(snapshot(&env, &state) == pre); + + // (b) a signed 1-input tx twice: DuplicateTransaction, not UtxoDoubleSpent. + let (sk, addr) = owner(11); + let dep = fund_deposit(&env, &state, addr, 4, 1000); + let pre = snapshot(&env, &state); + let t = authorization::authorize( + &[(addr, &sk)], + Transaction::new( + vec![dep], + vec![bitcoin_filled_output(addr, 1000).into()], + ), + )?; + let b = make_block(Some(g.0.hash()), vec![t.clone(), t.clone()], vec![]); + expect_reject( + "T5b apply_block(body with T twice)", + apply(&env, &state, &b.0, &b.1), + &duplicate_tx_reason(t.transaction.txid()), + )?; + anyhow::ensure!(snapshot(&env, &state) == pre); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T6 — money-path control: deposit spend -> spend of its output -> re-mining +// the first tx is refused -> disconnect^2 is byte-exact (deposit restored). +// --------------------------------------------------------------------------- + +#[test] +fn zero_input_t6_money_path_round_trip() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t6")?; + let (sk, addr) = owner(21); + let dep = fund_deposit(&env, &state, addr, 6, 5000); + let funded = snapshot(&env, &state); + + let t = authorization::authorize( + &[(addr, &sk)], + Transaction::new( + vec![dep], + vec![bitcoin_filled_output(addr, 5000).into()], + ), + )?; + anyhow::ensure!(validate(&env, &state, &t).is_ok()); + let g = make_block(None, vec![t.clone()], Vec::new()); + apply(&env, &state, &g.0, &g.1)?; + let tp = OutPoint::Regular { + txid: t.transaction.txid(), + vout: 0, + }; + let s = authorization::authorize( + &[(addr, &sk)], + Transaction::new( + vec![tp], + vec![bitcoin_filled_output(addr, 4000).into()], + ), + )?; + // coinbase claims S's 1000-sat fee + let h1 = make_block( + Some(g.0.hash()), + vec![s], + vec![Output::from(bitcoin_filled_output(addr, 1000))], + ); + apply(&env, &state, &h1.0, &h1.1)?; + + // T cannot be re-mined: its deposit input is spent. + let h2 = make_block(Some(h1.0.hash()), vec![t], Vec::new()); + let res = apply(&env, &state, &h2.0, &h2.1); + anyhow::ensure!(matches!(res, Err(Error::NoUtxo(_))), "{res:?}"); + + disconnect(&env, &state, &h1.0, &h1.1)?; + disconnect(&env, &state, &g.0, &g.1)?; + anyhow::ensure!(snapshot(&env, &state) == funded, "undo not byte-exact"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T7 — the output-key check covers every output content kind and every +// BitAsset / AMM / Dutch auction tx data variant that creates outputs. The +// check keys `Regular { txid, vout }` over `transaction.outputs`, independent +// of content and data, and runs before the content-specific checks, so each +// tx below only needs a valid, authorized input to reach it. +// --------------------------------------------------------------------------- + +/// Where the pre-existing row for the created outpoint is seeded. +#[derive(Clone, Copy)] +enum Seed { + Unspent, + Spent, +} + +/// Build {inputs: [signed deposit], outputs: [content -> owner], data}, seed +/// a row at its `Regular { txid, 0 }` key, and require the exact +/// `OutPointAlreadyExists` reason at mempool validation and at block connect, +/// with no state written. +fn assert_created_output_key_checked( + name: &str, + tag: u8, + content: OutputContent, + data: Option, + seed: Seed, +) -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state(name)?; + let (sk, addr) = owner(tag); + let dep = fund_deposit(&env, &state, addr, tag, 1000); + let tx = Transaction { + inputs: vec![dep], + outputs: vec![Output::new(addr, content)], + memo: Vec::new(), + data, + }; + let t = authorization::authorize(&[(addr, &sk)], tx)?; + let created = OutPoint::Regular { + txid: t.transaction.txid(), + vout: 0, + }; + let expected = outpoint_exists_reason(created); + + // Control: without the seeded row the tx is not rejected for this reason. + let res = validate(&env, &state, &t); + anyhow::ensure!( + !matches!(&res, Err(err) if err.to_string() == expected), + "{name}: rejected as re-created before seeding: {res:?}" + ); + + { + let mut rwtxn = env.write_txn()?; + let key = OutPointKey::from_outpoint(&created); + match seed { + Seed::Unspent => state.utxos.put( + &mut rwtxn, + &key, + &bitcoin_filled_output(ADDR_A, 0), + )?, + Seed::Spent => state.stxos.put( + &mut rwtxn, + &key, + &SpentOutput { + output: bitcoin_filled_output(ADDR_A, 0), + inpoint: InPoint::Regular { + txid: Txid([0x66; 32]), + vin: 0, + }, + }, + )?, + } + rwtxn.commit()?; + } + + expect_reject( + &format!("{name}: validate_transaction"), + validate(&env, &state, &t), + &expected, + )?; + let pre = snapshot(&env, &state); + let b = make_block(pre.tip, vec![t], vec![]); + expect_reject( + &format!("{name}: apply_block"), + apply(&env, &state, &b.0, &b.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "{name}: rejected block wrote state" + ); + Ok(()) +} + +fn some_bitasset() -> BitAssetId { + BitAssetId([0x42; 32]) +} + +#[test] +fn zero_input_t7a_bitasset_reservation_output_key_checked() -> anyhow::Result<()> +{ + assert_created_output_key_checked( + "zero_input_t7a", + 71, + OutputContent::BitAssetReservation, + Some(TxData::BitAssetReservation { + commitment: [0x07; 32], + }), + Seed::Unspent, + ) +} + +#[test] +fn zero_input_t7b_bitasset_mint_output_key_checked() -> anyhow::Result<()> { + assert_created_output_key_checked( + "zero_input_t7b", + 72, + OutputContent::BitAsset(5), + Some(TxData::BitAssetMint(5)), + Seed::Spent, + ) +} + +#[test] +fn zero_input_t7c_bitasset_update_control_output_key_checked() +-> anyhow::Result<()> { + let updates = BitAssetDataUpdates { + commitment: Update::Retain, + socket_addr_v4: Update::Retain, + socket_addr_v6: Update::Retain, + encryption_pubkey: Update::Retain, + signing_pubkey: Update::Retain, + }; + assert_created_output_key_checked( + "zero_input_t7c", + 73, + OutputContent::BitAssetControl, + Some(TxData::BitAssetUpdate(Box::new(updates))), + Seed::Unspent, + ) +} + +#[test] +fn zero_input_t7d_amm_mint_lp_token_output_key_checked() -> anyhow::Result<()> { + assert_created_output_key_checked( + "zero_input_t7d", + 74, + OutputContent::AmmLpToken(10), + Some(TxData::AmmMint { + amount0: 1, + amount1: 1, + lp_token_mint: 10, + }), + Seed::Spent, + ) +} + +#[test] +fn zero_input_t7e_amm_burn_output_key_checked() -> anyhow::Result<()> { + assert_created_output_key_checked( + "zero_input_t7e", + 75, + OutputContent::BitAsset(1), + Some(TxData::AmmBurn { + amount0: 1, + amount1: 1, + lp_token_burn: 10, + }), + Seed::Unspent, + ) +} + +#[test] +fn zero_input_t7f_dutch_auction_create_receipt_output_key_checked() +-> anyhow::Result<()> { + assert_created_output_key_checked( + "zero_input_t7f", + 76, + OutputContent::DutchAuctionReceipt, + Some(TxData::DutchAuctionCreate(DutchAuctionParams { + start_block: 1, + duration: 10, + base_asset: AssetId::BitAsset(some_bitasset()), + base_amount: 5, + quote_asset: AssetId::Bitcoin, + initial_price: 100, + final_price: 1, + })), + Seed::Spent, + ) +} + +#[test] +fn zero_input_t7g_dutch_auction_collect_output_key_checked() +-> anyhow::Result<()> { + assert_created_output_key_checked( + "zero_input_t7g", + 77, + OutputContent::BitAsset(5), + Some(TxData::DutchAuctionCollect { + asset_offered: AssetId::BitAsset(some_bitasset()), + asset_receive: AssetId::Bitcoin, + amount_offered_remaining: 5, + amount_received: 0, + }), + Seed::Unspent, + ) +} From 0f742eaf11d437482bcd0032f8e63abff0a0dfd4 Mon Sep 17 00:00:00 2001 From: mblowes Date: Wed, 16 Sep 2026 06:56:30 +1000 Subject: [PATCH 2/2] Require at least one input per transaction 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 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) --- app/gui/bitassets/reserve_register.rs | 10 +- app/rpc_server.rs | 8 +- lib/state/error.rs | 2 + lib/state/mod.rs | 9 + lib/state/zero_input_replay_tests.rs | 228 +++++++++++++++++++------- lib/wallet.rs | 151 ++++++++++++++++- 6 files changed, 340 insertions(+), 68 deletions(-) diff --git a/app/gui/bitassets/reserve_register.rs b/app/gui/bitassets/reserve_register.rs index 18163982..8b827cce 100644 --- a/app/gui/bitassets/reserve_register.rs +++ b/app/gui/bitassets/reserve_register.rs @@ -1,7 +1,7 @@ use std::borrow::Cow; use eframe::egui; -use plain_bitassets::types::BitAssetData; +use plain_bitassets::types::{BitAssetData, Transaction}; use crate::{ app::App, @@ -25,7 +25,13 @@ fn register_bitasset( bitasset_data: Cow, fee: bitcoin::Amount, ) -> anyhow::Result<()> { - let mut tx = app.wallet.create_regular_transaction(fee)?; + // The reservation input funds a zero-fee registration, so only select + // Bitcoin inputs if there is a fee to pay. + let mut tx = if fee == bitcoin::Amount::ZERO { + Transaction::default() + } else { + app.wallet.create_regular_transaction(fee)? + }; let () = app.wallet.register_bitasset( &mut tx, plaintext_name, diff --git a/app/rpc_server.rs b/app/rpc_server.rs index d4950c63..5edb6b5b 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -599,7 +599,13 @@ impl RpcServer for RpcServerImpl { } async fn reserve_bitasset(&self, plain_name: String) -> RpcResult { - let mut tx = Transaction::default(); + // Consensus requires at least one input, so fund the reservation with + // a zero-fee regular transaction. + let mut tx = self + .app + .wallet + .create_regular_transaction(Amount::ZERO) + .map_err(custom_err)?; let () = match self.app.wallet.reserve_bitasset(&mut tx, &plain_name) { Ok(()) => (), Err(err) => return Err(custom_err(err)), diff --git a/lib/state/error.rs b/lib/state/error.rs index 61be445a..467d82b3 100644 --- a/lib/state/error.rs +++ b/lib/state/error.rs @@ -387,6 +387,8 @@ pub enum Error { MissingBitAssetInput { name_hash: Hash }, #[error("deposit block doesn't exist")] NoDepositBlock, + #[error("transaction {txid} has no inputs")] + NoInputs { txid: Txid }, #[error("total fees less than coinbase value")] NotEnoughFees, #[error("no tip")] diff --git a/lib/state/mod.rs b/lib/state/mod.rs index beacccb6..b7ad7aee 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -638,6 +638,15 @@ impl State { rotxn: &RoTxn, tx: &FilledTransaction, ) -> Result { + // A transaction must spend at least one input. The coinbase is not a + // `Transaction` and is not validated here. With this rule every txid + // commits to an outpoint that can be spent only once, so a + // transaction cannot be mined twice on one chain, and a zero-output + // transaction (which creates no keys for the check below to catch) + // cannot be replayed either. + if tx.transaction.inputs.is_empty() { + return Err(Error::NoInputs { txid: tx.txid() }); + } // The outpoints created by this transaction must not already exist // (see `ensure_outpoint_is_new`). A transaction's txid is a pure // function of its contents, so an identical transaction can be mined diff --git a/lib/state/zero_input_replay_tests.rs b/lib/state/zero_input_replay_tests.rs index f1daf467..3df2d2c2 100644 --- a/lib/state/zero_input_replay_tests.rs +++ b/lib/state/zero_input_replay_tests.rs @@ -25,6 +25,8 @@ //! //! ## The fix (consensus change) //! +//! - `State::validate_filled_transaction`: a transaction must have >= 1 input +//! -> `Error::NoInputs` (T1, T2, T8). //! - `State::validate_filled_transaction`: a tx output key may not already //! exist in `utxos` or `stxos` -> `Error::OutPointAlreadyExists`. This is //! independent of output content and tx data, so it covers BitAsset, AMM and @@ -33,8 +35,8 @@ //! -> `Error::DuplicateTransaction`; coinbase output keys may not already //! exist in `utxos` or `stxos` -> `Error::OutPointAlreadyExists`. //! -//! A zero-input transaction is still valid the first time it is mined; only -//! its replay is rejected (T2). +//! The output-key and body checks alone already reject every re-creation; +//! the input-count rule additionally rejects zero-input transactions outright. //! //! `validate_filled_transaction` is shared by the mempool path //! (`validate_transaction`: `submit_transaction`, peer `handle_push_tx`, the @@ -61,6 +63,7 @@ use crate::{ BitAssetId, BlockHash, Body, DutchAuctionParams, FilledOutput, Header, InPoint, OutPoint, OutPointKey, Output, OutputContent, SpentOutput, Transaction, TxData, Txid, Update, VerifyingKey, + WithdrawalOutputContent, }, }; @@ -70,6 +73,10 @@ const ADDR_A: Address = Address([0xAA; 20]); // Exact reject reasons. // --------------------------------------------------------------------------- +fn no_inputs_reason(txid: Txid) -> String { + format!("transaction {txid} has no inputs") +} + fn outpoint_exists_reason(outpoint: OutPoint) -> String { format!("output {outpoint} already exists") } @@ -229,90 +236,149 @@ fn fund_deposit( } // --------------------------------------------------------------------------- -// T2 — replay of a zero-input tx. Z is admitted and mined once; mining it -// again is rejected by the output-key check whether (Z, 0) is unspent or -// spent, nothing is written, and undo stays exact. +// T1 — the input-count rule at mempool: every zero-input shape is rejected with NoInputs. // --------------------------------------------------------------------------- #[test] -fn zero_input_t2_zero_input_tx_replay_rejected() -> anyhow::Result<()> { - let (_tmp, env, state) = fresh_state("zero_input_t2")?; - let empty = snapshot(&env, &state); - let (sk, addr) = owner(5); - let z = unauthorized(Transaction::new( - Vec::new(), - vec![bitcoin_filled_output(addr, 0).into()], - )); - let zp = OutPoint::Regular { - txid: z.transaction.txid(), - vout: 0, +fn zero_input_t1_mempool_rejects_zero_input_txs() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t1")?; + + // (a) Bitcoin(0) output, no inputs, no authorizations. + let z = zero_input_tx(0); + expect_reject( + "T1a zero-input Bitcoin(0): validate_transaction", + validate(&env, &state, &unauthorized(z.clone())), + &no_inputs_reason(z.txid()), + )?; + // The authorization layer still accepts 0 == 0: the rule lives in state, + // on the shared mempool/connect choke point, not in authorization. + let res = + authorization::verify_authorized_transaction(&unauthorized(z.clone())); + anyhow::ensure!(res.is_ok(), "authorization layer unchanged: {res:?}"); + + // (a'') a memo makes a DIFFERENT txid: still rejected. + let mut z_memo = zero_input_tx(0); + z_memo.memo = b"anything".to_vec(); + expect_reject( + "T1a'' zero-input Bitcoin(0)+memo", + validate(&env, &state, &unauthorized(z_memo.clone())), + &no_inputs_reason(z_memo.txid()), + )?; + + // (b) non-zero output value: rejected before value conservation. + let z1 = zero_input_tx(1); + expect_reject( + "T1b zero-input Bitcoin(1)", + validate(&env, &state, &unauthorized(z1.clone())), + &no_inputs_reason(z1.txid()), + )?; + + // (c) zero-value WITHDRAWAL output from nothing. + let main_address = { + let pkh = bitcoin::PubkeyHash::hash(b"zero-input withdrawal"); + bitcoin::Address::p2pkh(pkh, bitcoin::NetworkKind::Test) + .into_unchecked() }; + let zw = Transaction::new( + Vec::new(), + vec![Output::new( + ADDR_A, + OutputContent::Withdrawal(WithdrawalOutputContent { + value: bitcoin::Amount::ZERO, + main_fee: bitcoin::Amount::ZERO, + main_address, + }), + )], + ); + expect_reject( + "T1c zero-input Withdrawal(0,0)", + validate(&env, &state, &unauthorized(zw.clone())), + &no_inputs_reason(zw.txid()), + )?; + + // (d) zero-input, ZERO-output tx (creates no keys, so only the input-count + // rule stops its unbounded free replay). + let empty = Transaction::new(Vec::new(), Vec::new()); + expect_reject( + "T1d zero-input zero-output", + validate(&env, &state, &unauthorized(empty.clone())), + &no_inputs_reason(empty.txid()), + )?; - // Z validates and connects the first time. - let res = validate(&env, &state, &z); + // Positive control: a signed 1-input spend of a deposit validates. + let (sk, addr) = owner(3); + let dep = fund_deposit(&env, &state, addr, 1, 1000); + let t = authorization::authorize( + &[(addr, &sk)], + Transaction::new( + vec![dep], + vec![bitcoin_filled_output(addr, 900).into()], + ), + )?; + let res = validate(&env, &state, &t); anyhow::ensure!( - matches!(res, Ok(fee) if fee == bitcoin::Amount::ZERO), - "first Z must validate: {res:?}" + matches!(res, Ok(fee) if fee == bitcoin::Amount::from_sat(100)), + "money-path control must validate: {res:?}" ); - let g = make_block(None, vec![z.clone()], Vec::new()); + Ok(()) +} + +// --------------------------------------------------------------------------- +// T2 — the input-count rule at block connect: a body carrying a zero-input tx is rejected, and +// nothing is written. +// --------------------------------------------------------------------------- + +#[test] +fn zero_input_t2_block_with_zero_input_tx_rejected() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t2")?; + let z = zero_input_tx(0); + + let g = make_block(None, Vec::new(), Vec::new()); apply(&env, &state, &g.0, &g.1)?; - let after_g = snapshot(&env, &state); - anyhow::ensure!(after_g.utxos.contains_key(&zp)); - let expected = outpoint_exists_reason(zp); + let pre = snapshot(&env, &state); - // (a) Replay while (Z, 0) is unspent. - expect_reject( - "T2a validate_transaction(Z again, (Z,0) unspent)", - validate(&env, &state, &z), - &expected, - )?; - let h1 = make_block(Some(g.0.hash()), vec![z.clone()], Vec::new()); + // (a) Z alone. + let h1 = + make_block(Some(g.0.hash()), vec![unauthorized(z.clone())], vec![]); expect_reject( - "T2a apply_block({Z} again, (Z,0) unspent)", + "T2a apply_block(body {Z})", apply(&env, &state, &h1.0, &h1.1), - &expected, + &no_inputs_reason(z.txid()), )?; anyhow::ensure!( - snapshot(&env, &state) == after_g, + snapshot(&env, &state) == pre, "rejected block wrote state" ); - // (b) Spend (Z, 0), then replay Z: the key is now in `stxos`, and - // re-creating it would resurrect the spent output. - let s = authorization::authorize( + // (b) Z after a valid signed spend in the same body. + let (sk, addr) = owner(5); + let dep = fund_deposit(&env, &state, addr, 2, 1000); + let pre = snapshot(&env, &state); + let t = authorization::authorize( &[(addr, &sk)], - Transaction::new(vec![zp], vec![bitcoin_filled_output(addr, 0).into()]), + Transaction::new( + vec![dep], + vec![bitcoin_filled_output(addr, 1000).into()], + ), )?; - let h1 = make_block(Some(g.0.hash()), vec![s], Vec::new()); - apply(&env, &state, &h1.0, &h1.1)?; - let spent = snapshot(&env, &state); - anyhow::ensure!( - !spent.utxos.contains_key(&zp) && spent.stxos.contains_key(&zp) + let h1b = make_block( + Some(g.0.hash()), + vec![t.clone(), unauthorized(z.clone())], + vec![], ); expect_reject( - "T2b validate_transaction(Z again, (Z,0) spent)", - validate(&env, &state, &z), - &expected, - )?; - let h2 = make_block(Some(h1.0.hash()), vec![z.clone()], Vec::new()); - expect_reject( - "T2b apply_block({Z} again, (Z,0) spent)", - apply(&env, &state, &h2.0, &h2.1), - &expected, + "T2b apply_block(body {T, Z})", + apply(&env, &state, &h1b.0, &h1b.1), + &no_inputs_reason(z.txid()), )?; anyhow::ensure!( - snapshot(&env, &state) == spent, + snapshot(&env, &state) == pre, "rejected block wrote state" ); - // Undo is exact, and once g is disconnected (Z, 0) no longer exists, so Z - // can be mined again on the new branch. - disconnect(&env, &state, &h1.0, &h1.1)?; - anyhow::ensure!(snapshot(&env, &state) == after_g, "undo not exact"); - disconnect(&env, &state, &g.0, &g.1)?; - anyhow::ensure!(snapshot(&env, &state) == empty, "undo not exact"); - apply(&env, &state, &g.0, &g.1)?; - anyhow::ensure!(snapshot(&env, &state) == after_g); + // Control: the same body without Z connects. + let h1c = make_block(Some(g.0.hash()), vec![t], vec![]); + apply(&env, &state, &h1c.0, &h1c.1)?; Ok(()) } @@ -528,6 +594,7 @@ fn zero_input_t5_duplicate_txid_in_one_body_rejected() -> anyhow::Result<()> { // (a) Z twice. Transaction checks run against the pre-block state, so // without the body rule both copies would pass the output-key check. + // DuplicateTransaction is reported before NoInputs. let z = zero_input_tx(0); let pre = snapshot(&env, &state); let b = make_block( @@ -831,3 +898,42 @@ fn zero_input_t7g_dutch_auction_collect_output_key_checked() Seed::Unspent, ) } + +// --------------------------------------------------------------------------- +// T8 — the input-count rule for a zero-input BitAsset reservation, the shape the +// `reserve_bitasset` RPC builds from `Transaction::default()`: no inputs, one +// reservation output, zero fee, no authorizations. It validated before this +// rule; now it is rejected (the RPC now funds it with an input). +// --------------------------------------------------------------------------- + +#[test] +fn zero_input_t8_zero_input_reservation_rejected() -> anyhow::Result<()> { + let (_tmp, env, state) = fresh_state("zero_input_t8")?; + let reservation = Transaction { + inputs: Vec::new(), + outputs: vec![Output::new(ADDR_A, OutputContent::BitAssetReservation)], + memo: Vec::new(), + data: Some(TxData::BitAssetReservation { + commitment: [0x08; 32], + }), + }; + let expected = no_inputs_reason(reservation.txid()); + let r = unauthorized(reservation); + expect_reject( + "T8 validate_transaction(zero-input reservation)", + validate(&env, &state, &r), + &expected, + )?; + let pre = snapshot(&env, &state); + let b = make_block(pre.tip, vec![r], vec![]); + expect_reject( + "T8 apply_block({zero-input reservation})", + apply(&env, &state, &b.0, &b.1), + &expected, + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "rejected block wrote state" + ); + Ok(()) +} diff --git a/lib/wallet.rs b/lib/wallet.rs index 43a4d8b3..ba56152f 100644 --- a/lib/wallet.rs +++ b/lib/wallet.rs @@ -461,6 +461,8 @@ impl Wallet { } /// Create a transaction with a fee only. + /// The transaction spends at least one Bitcoin input, even if the fee is + /// zero, since consensus requires every transaction to have an input. pub fn create_regular_transaction( &self, fee: bitcoin::Amount, @@ -549,13 +551,22 @@ impl Wallet { fee: bitcoin::Amount, memo: Option>, ) -> Result { - let (total_sats, bitcoins) = self.select_bitcoins(fee)?; + // The BitAsset inputs fund a zero-fee transfer, so only select + // Bitcoin inputs if there is a fee to pay. + let (total_sats, bitcoins) = if fee == Amount::ZERO { + (Amount::ZERO, HashMap::new()) + } else { + self.select_bitcoins(fee)? + }; let change_sats = total_sats - fee; let mut inputs: Vec<_> = bitcoins.into_keys().collect(); let (total_bitasset, bitasset_utxos) = self.select_bitasset_utxos(asset_id, amount)?; let bitasset_change = total_bitasset - amount; inputs.extend(bitasset_utxos.into_keys()); + if inputs.is_empty() { + return Err(Error::NotEnoughFunds); + } let mut outputs = vec![Output { address, content: OutputContent::BitAsset(amount), @@ -731,6 +742,10 @@ impl Wallet { Ok(()) } + /// Select Bitcoin UTXOs worth at least `value`. + /// At least one UTXO with a non-zero value is selected, even if `value` is + /// zero, so that a transaction funded this way always has an input. + /// Returns `NotEnoughFunds` if no such UTXO exists. pub fn select_bitcoins( &self, value: bitcoin::Amount, @@ -773,7 +788,7 @@ impl Wallet { { continue; } - if total >= value { + if total >= value && !selected.is_empty() { break; } total = total @@ -781,7 +796,7 @@ impl Wallet { .ok_or(AmountOverflowError)?; selected.insert(outpoint, output.clone()); } - if total >= value { + if total >= value && !selected.is_empty() { Ok((total, selected)) } else { Err(Error::NotEnoughFunds) @@ -1653,7 +1668,135 @@ impl Watchable<()> for Wallet { #[cfg(test)] mod test { - use crate::wallet::Wallet; + use std::collections::HashMap; + + use bitcoin::{Amount, hashes::Hash as _}; + + use crate::{ + types::{ + BitAssetId, FilledOutput, FilledOutputContent, OutPoint, + OutputContent, + }, + wallet::{Error, Wallet}, + }; + + /// A fresh wallet with a seed, in a unique temp dir. + fn seeded_wallet( + name: &str, + ) -> anyhow::Result<(std::path::PathBuf, Wallet)> { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos(); + let test_dir = std::env::temp_dir() + .join(format!("bitassets_test_wallet_{name}_{nanos}")); + let wallet = Wallet::new(&test_dir)?; + wallet.set_seed(&[2u8; 64])?; + Ok((test_dir, wallet)) + } + + fn deposit_outpoint(tag: u8) -> OutPoint { + OutPoint::Deposit(bitcoin::OutPoint { + txid: bitcoin::Txid::from_byte_array([tag; 32]), + vout: 0, + }) + } + + /// Zero-value builders must still spend an input, since consensus rejects + /// transactions without inputs. + #[test] + fn zero_value_builders_spend_an_input() -> anyhow::Result<()> { + let (test_dir, wallet) = seeded_wallet("zero_value")?; + let addr = wallet.get_new_address()?; + + // No spendable Bitcoin: a zero-fee regular tx cannot be funded. + wallet.put_utxos(&HashMap::from([( + deposit_outpoint(1), + FilledOutput::new_bitcoin_value(addr, Amount::ZERO), + )]))?; + let res = wallet.select_bitcoins(Amount::ZERO); + anyhow::ensure!( + matches!(res, Err(Error::NotEnoughFunds)), + "zero-value UTXOs only: {res:?}" + ); + let res = wallet.create_regular_transaction(Amount::ZERO); + anyhow::ensure!( + matches!(res, Err(Error::NotEnoughFunds)), + "zero-value UTXOs only: {res:?}" + ); + + let funded = deposit_outpoint(2); + wallet.put_utxos(&HashMap::from([( + funded, + FilledOutput::new_bitcoin_value(addr, Amount::from_sat(500)), + )]))?; + let (total, selected) = wallet.select_bitcoins(Amount::ZERO)?; + anyhow::ensure!(total == Amount::from_sat(500)); + anyhow::ensure!(selected.keys().eq([&funded]), "{selected:?}"); + + // Zero-fee regular tx (the funding for a BitAsset reservation). + let tx = wallet.create_regular_transaction(Amount::ZERO)?; + anyhow::ensure!(tx.inputs == vec![funded], "{tx:?}"); + let mut reservation = tx; + wallet.reserve_bitasset(&mut reservation, "zero-fee-name")?; + anyhow::ensure!(reservation.inputs == vec![funded]); + anyhow::ensure!(reservation.is_reservation()); + + // Zero-value, zero-fee transfer and withdrawal. + let tx = + wallet.create_transfer(addr, Amount::ZERO, Amount::ZERO, None)?; + anyhow::ensure!(tx.inputs == vec![funded], "{tx:?}"); + let main_address = { + let pkh = bitcoin::PubkeyHash::hash(b"zero-value withdrawal"); + bitcoin::Address::p2pkh(pkh, bitcoin::NetworkKind::Test) + .into_unchecked() + }; + let tx = wallet.create_withdrawal( + main_address, + Amount::ZERO, + Amount::ZERO, + Amount::ZERO, + )?; + anyhow::ensure!(tx.inputs == vec![funded], "{tx:?}"); + + // A zero-fee BitAsset transfer is funded by its BitAsset input and + // does not need Bitcoin. + let bitasset = BitAssetId([0x42; 32]); + let bitasset_outpoint = deposit_outpoint(3); + wallet.put_utxos(&HashMap::from([( + bitasset_outpoint, + FilledOutput::new(addr, FilledOutputContent::BitAsset(bitasset, 7)), + )]))?; + let tx = wallet.create_bitasset_transfer( + addr, + bitasset, + 7, + Amount::ZERO, + None, + )?; + anyhow::ensure!(tx.inputs == vec![bitasset_outpoint], "{tx:?}"); + anyhow::ensure!( + tx.outputs.iter().all(|output| matches!( + output.content, + OutputContent::BitAsset(7) + )), + "{tx:?}" + ); + // With no holdings of the BitAsset, there is nothing to spend. + let res = wallet.create_bitasset_transfer( + addr, + BitAssetId([0x43; 32]), + 0, + Amount::ZERO, + None, + ); + anyhow::ensure!( + matches!(res, Err(Error::NotEnoughFunds)), + "no inputs available: {res:?}" + ); + + let _unused = std::fs::remove_dir_all(&test_dir); + Ok(()) + } #[test] fn test_get_or_generate_last_address() -> anyhow::Result<()> {