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/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..467d82b3 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( @@ -385,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")] @@ -397,6 +401,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..b7ad7aee 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,72 @@ 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 { + // 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 + // 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..3df2d2c2 --- /dev/null +++ b/lib/state/zero_input_replay_tests.rs @@ -0,0 +1,939 @@ +//! 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 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 +//! 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`. +//! +//! 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 +//! 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, + WithdrawalOutputContent, + }, +}; + +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") +} + +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 +} + +// --------------------------------------------------------------------------- +// T1 — the input-count rule at mempool: every zero-input shape is rejected with NoInputs. +// --------------------------------------------------------------------------- + +#[test] +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()), + )?; + + // 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::from_sat(100)), + "money-path control must validate: {res:?}" + ); + 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 pre = snapshot(&env, &state); + + // (a) Z alone. + let h1 = + make_block(Some(g.0.hash()), vec![unauthorized(z.clone())], vec![]); + expect_reject( + "T2a apply_block(body {Z})", + apply(&env, &state, &h1.0, &h1.1), + &no_inputs_reason(z.txid()), + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "rejected block wrote state" + ); + + // (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![dep], + vec![bitcoin_filled_output(addr, 1000).into()], + ), + )?; + let h1b = make_block( + Some(g.0.hash()), + vec![t.clone(), unauthorized(z.clone())], + vec![], + ); + expect_reject( + "T2b apply_block(body {T, Z})", + apply(&env, &state, &h1b.0, &h1b.1), + &no_inputs_reason(z.txid()), + )?; + anyhow::ensure!( + snapshot(&env, &state) == pre, + "rejected block wrote state" + ); + + // 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(()) +} + +// --------------------------------------------------------------------------- +// 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. + // DuplicateTransaction is reported before NoInputs. + 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, + ) +} + +// --------------------------------------------------------------------------- +// 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<()> {