Skip to content

Commit 593ca88

Browse files
joostjagerclaude
andcommitted
chanmon_consistency: track UTXOs to reject invalid transactions
The fuzzer's ChainState would confirm transactions that spend the same input as an already-confirmed transaction, or spend outputs that were never created (due to fuzz txid hash collisions). Both are impossible on a real blockchain. This caused spurious assertion failures when e.g. a splice tx and an old holder commitment tx both got confirmed despite spending the same funding outpoint. Add UTXO tracking to ChainState: confirmed transaction outputs are added to a UTXO set, spent inputs are removed, and new transactions are rejected unless all their inputs reference existing UTXOs. This naturally prevents both double-spends and phantom output spends. Also give each node 50 wallet UTXOs (up from 1) so that anchor-channel HTLC claims don't exhaust the wallet during settlement. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2f13a42 commit 593ca88

1 file changed

Lines changed: 53 additions & 16 deletions

File tree

fuzz/src/chanmon_consistency.rs

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use bitcoin::opcodes;
2626
use bitcoin::script::{Builder, ScriptBuf};
2727
use bitcoin::transaction::Version;
2828
use bitcoin::transaction::{Transaction, TxOut};
29+
use bitcoin::OutPoint as BitcoinOutPoint;
2930
use bitcoin::FeeRate;
3031

3132
use bitcoin::block::Header;
@@ -187,13 +188,22 @@ impl BroadcasterInterface for TestBroadcaster {
187188
struct ChainState {
188189
blocks: Vec<(Header, Vec<Transaction>)>,
189190
confirmed_txids: HashSet<Txid>,
191+
/// Tracks unspent outputs created by confirmed transactions. Only
192+
/// transactions that spend existing UTXOs can be confirmed, which
193+
/// prevents fuzz hash collisions from creating phantom spends of
194+
/// outputs that were never actually created.
195+
utxos: HashSet<BitcoinOutPoint>,
190196
}
191197

192198
impl ChainState {
193199
fn new() -> Self {
194200
let genesis_hash = genesis_block(Network::Bitcoin).block_hash();
195201
let genesis_header = create_dummy_header(genesis_hash, 42);
196-
Self { blocks: vec![(genesis_header, Vec::new())], confirmed_txids: HashSet::new() }
202+
Self {
203+
blocks: vec![(genesis_header, Vec::new())],
204+
confirmed_txids: HashSet::new(),
205+
utxos: HashSet::new(),
206+
}
197207
}
198208

199209
fn tip_height(&self) -> u32 {
@@ -205,7 +215,28 @@ impl ChainState {
205215
if self.confirmed_txids.contains(&txid) {
206216
return false;
207217
}
218+
// Validate that all inputs spend existing, unspent outputs. This
219+
// rejects both double-spends and spends of outputs that were never
220+
// created (e.g. due to fuzz txid hash collisions where a different
221+
// transaction was confirmed under the same txid).
222+
let is_coinbase = tx.is_coinbase();
223+
if !is_coinbase {
224+
for input in &tx.input {
225+
if !self.utxos.contains(&input.previous_output) {
226+
return false;
227+
}
228+
}
229+
}
208230
self.confirmed_txids.insert(txid);
231+
if !is_coinbase {
232+
for input in &tx.input {
233+
self.utxos.remove(&input.previous_output);
234+
}
235+
}
236+
// Add this transaction's outputs as new UTXOs.
237+
for idx in 0..tx.output.len() {
238+
self.utxos.insert(BitcoinOutPoint { txid, vout: idx as u32 });
239+
}
209240

210241
let prev_hash = self.blocks.last().unwrap().0.block_hash();
211242
let header = create_dummy_header(prev_hash, 42);
@@ -1253,21 +1284,27 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(
12531284
let wallet_c = TestWalletSource::new(SecretKey::from_slice(&[3; 32]).unwrap());
12541285

12551286
let wallets = vec![wallet_a, wallet_b, wallet_c];
1256-
let coinbase_tx = bitcoin::Transaction {
1257-
version: bitcoin::transaction::Version::TWO,
1258-
lock_time: bitcoin::absolute::LockTime::ZERO,
1259-
input: vec![bitcoin::TxIn { ..Default::default() }],
1260-
output: wallets
1261-
.iter()
1262-
.map(|w| TxOut {
1263-
value: Amount::from_sat(100_000),
1264-
script_pubkey: w.get_change_script().unwrap(),
1265-
})
1266-
.collect(),
1267-
};
1268-
wallets.iter().enumerate().for_each(|(i, w)| {
1269-
w.add_utxo(coinbase_tx.clone(), i as u32);
1270-
});
1287+
// Create wallet UTXOs for each node. Each anchor-channel HTLC claim
1288+
// needs a wallet input for fees, so we create enough UTXOs to cover
1289+
// multiple concurrent claims.
1290+
let num_wallet_utxos = 50;
1291+
for (wallet_idx, w) in wallets.iter().enumerate() {
1292+
let coinbase_tx = bitcoin::Transaction {
1293+
version: bitcoin::transaction::Version(wallet_idx as i32 + 100),
1294+
lock_time: bitcoin::absolute::LockTime::ZERO,
1295+
input: vec![bitcoin::TxIn { ..Default::default() }],
1296+
output: (0..num_wallet_utxos)
1297+
.map(|_| TxOut {
1298+
value: Amount::from_sat(100_000),
1299+
script_pubkey: w.get_change_script().unwrap(),
1300+
})
1301+
.collect(),
1302+
};
1303+
for vout in 0..num_wallet_utxos {
1304+
w.add_utxo(coinbase_tx.clone(), vout);
1305+
}
1306+
chain_state.confirm_tx(coinbase_tx);
1307+
}
12711308

12721309
let fee_est_a = Arc::new(FuzzEstimator { ret_val: atomic::AtomicU32::new(253) });
12731310
let mut last_htlc_clear_fee_a = 253;

0 commit comments

Comments
 (0)