Skip to content

Commit abfa486

Browse files
committed
Tx validation: check utxo hashes for utreexo
1 parent 6ab073c commit abfa486

6 files changed

Lines changed: 345 additions & 187 deletions

File tree

lib/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ tracing = { workspace = true }
6363
transitive = { workspace = true }
6464
utoipa = { workspace = true, features = ["macros", "non_strict_integers"] }
6565

66+
[dev-dependencies]
67+
rand = { workspace = true, features = ["std_rng"] }
68+
6669
[features]
6770
clap = ["dep:clap"]
6871

lib/state/block.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,3 +619,159 @@ pub fn disconnect_tip(
619619
.map_err(DbError::from)?;
620620
Ok(())
621621
}
622+
623+
#[cfg(test)]
624+
mod test {
625+
use crate::state::test::{fresh_state, value_output};
626+
627+
#[test]
628+
fn validation_rejects_outpoint_utxo_hash_mismatch() -> anyhow::Result<()> {
629+
use bitcoin::hashes::Hash as _;
630+
use rustreexo::accumulator::node_hash::BitcoinNodeHash;
631+
632+
use crate::{
633+
authorization::{SigningKey, authorize, get_address},
634+
types::{
635+
Accumulator, AccumulatorDiff, Body, Header, OutPoint,
636+
OutPointKey, PointedOutput, Transaction, hash,
637+
},
638+
};
639+
let (env, state) =
640+
fresh_state("validation_rejects_outpoint_utxo_hash_mismatch")?;
641+
642+
// Attacker key (owns A). Victim key (owns B).
643+
let attacker = SigningKey::from_seeds(
644+
&[0x11; fips205::slh_dsa_shake_256s::N],
645+
&[0x11; fips205::slh_dsa_shake_256s::N],
646+
&[0x11; fips205::slh_dsa_shake_256s::N],
647+
);
648+
let attacker_addr = get_address(&attacker.verifying_key());
649+
let victim = SigningKey::from_seeds(
650+
&[0x22; fips205::slh_dsa_shake_256s::N],
651+
&[0x22; fips205::slh_dsa_shake_256s::N],
652+
&[0x22; fips205::slh_dsa_shake_256s::N],
653+
);
654+
let victim_addr = get_address(&victim.verifying_key());
655+
656+
// UTXO A (attacker, 10_000) and victim UTXO B (20_000).
657+
let outpoint_a = OutPoint::Deposit(bitcoin::OutPoint {
658+
txid: bitcoin::Txid::from_byte_array([0xAA; 32]),
659+
vout: 0,
660+
});
661+
let output_a = value_output(attacker_addr, 10_000);
662+
let outpoint_b = OutPoint::Deposit(bitcoin::OutPoint {
663+
txid: bitcoin::Txid::from_byte_array([0xBB; 32]),
664+
vout: 0,
665+
});
666+
let output_b = value_output(victim_addr, 20_000);
667+
668+
// Leaf hashes for A and B (the Utreexo commitments).
669+
let pointed_a = PointedOutput {
670+
outpoint: outpoint_a,
671+
output: output_a.clone(),
672+
};
673+
let pointed_b = PointedOutput {
674+
outpoint: outpoint_b,
675+
output: output_b.clone(),
676+
};
677+
let leaf_a: BitcoinNodeHash = (&pointed_a).into();
678+
let leaf_b: BitcoinNodeHash = (&pointed_b).into();
679+
let hash_b: crate::types::Hash = hash(&pointed_b); // input's utxo_hash
680+
681+
// Helper: build a fresh accumulator seeded with leaves A and B
682+
// (Accumulator is not Clone, so re-seed when a fresh copy is needed).
683+
let seeded_accumulator = || -> anyhow::Result<_> {
684+
let mut acc = Accumulator::default();
685+
let mut diff = AccumulatorDiff::default();
686+
diff.insert(leaf_a);
687+
diff.insert(leaf_b);
688+
acc.apply_diff(diff)?;
689+
Ok(acc)
690+
};
691+
692+
// Seed UTXO DB with A and B; seed the accumulator with both leaves.
693+
let pre_accumulator = seeded_accumulator()?;
694+
{
695+
let mut rwtxn = env.write_txn()?;
696+
state.utxos.put(
697+
&mut rwtxn,
698+
&OutPointKey::from(&outpoint_a),
699+
&output_a,
700+
)?;
701+
state.utxos.put(
702+
&mut rwtxn,
703+
&OutPointKey::from(&outpoint_b),
704+
&output_b,
705+
)?;
706+
state
707+
.utreexo_accumulator
708+
.put(&mut rwtxn, &(), &pre_accumulator)?;
709+
// tip stays unset (None) so validate's prev_side_hash check
710+
// expects header.prev_side_hash == None.
711+
rwtxn.commit()?;
712+
}
713+
714+
// Build the malicious tx: input = (outpoint_A, hash_B) + proof for B;
715+
// output C = 9_000 to the attacker.
716+
let proof_for_b = pre_accumulator.prove(&[leaf_b])?;
717+
let output_c = value_output(attacker_addr, 9_000);
718+
let tx = Transaction {
719+
inputs: vec![(outpoint_a, hash_b)],
720+
proof: proof_for_b,
721+
outputs: vec![output_c.clone()],
722+
};
723+
// Sign with A's key (the spender of outpoint A authorizes the tx).
724+
let authorized = authorize(
725+
&mut rand::thread_rng(),
726+
&[(attacker_addr, &attacker)],
727+
tx,
728+
)?;
729+
730+
// Assemble body.
731+
let body = Body::new(vec![authorized], Vec::new());
732+
733+
// Compute the header the validator expects:
734+
// merkle_root from the filled tx, roots = post-block accumulator
735+
// (B's leaf removed, C's leaf inserted) -- exactly the diff validate
736+
// builds from the SUPPLIED utxo_hash.
737+
let filled = {
738+
let rotxn = env.read_txn()?;
739+
state.fill_transaction(&rotxn, &body.transactions[0])?
740+
};
741+
742+
// tx validation REJECTS the outpoint/utxo_hash mismatch.
743+
anyhow::ensure!(state.validate_filled_transaction(&filled).is_err());
744+
let merkle_root =
745+
Body::compute_merkle_root(body.coinbase.as_slice(), &[filled])?;
746+
let mut post_accumulator = seeded_accumulator()?;
747+
{
748+
let mut diff = AccumulatorDiff::default();
749+
// validate removes the SUPPLIED utxo_hash (B), inserts output C.
750+
diff.remove(leaf_b);
751+
let txid = body.transactions[0].txid();
752+
let pointed_c = PointedOutput {
753+
outpoint: OutPoint::Regular { txid, vout: 0 },
754+
output: output_c.clone(),
755+
};
756+
diff.insert((&pointed_c).into());
757+
post_accumulator.apply_diff(diff)?;
758+
}
759+
let header = Header {
760+
merkle_root,
761+
prev_side_hash: None,
762+
prev_main_hash: bitcoin::BlockHash::from_byte_array([0u8; 32]),
763+
roots: post_accumulator.get_roots(),
764+
};
765+
766+
// block validation REJECTS the outpoint/utxo_hash mismatch.
767+
{
768+
let rotxn = env.read_txn()?;
769+
anyhow::ensure!(
770+
state.validate_block(&rotxn, &header, &body).is_err(),
771+
"BUG: real validate_block accepts an input whose outpoint (A) \
772+
and utxo_hash (B) refer to different UTXOs",
773+
);
774+
}
775+
Ok(())
776+
}
777+
}

lib/state/error.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@ pub enum Error {
174174
UtreexoRootsMismatch,
175175
#[error("utxo double spent")]
176176
UtxoDoubleSpent,
177+
#[error(
178+
"Computed Utxo hash ({}) for input ({}) does not match input hash ({})",
179+
hex::encode(.computed),
180+
.outpoint,
181+
hex::encode(.input_hash),
182+
)]
183+
UtxoHashMismatch {
184+
computed: crate::types::Hash,
185+
outpoint: OutPoint,
186+
input_hash: crate::types::Hash,
187+
},
177188
#[error("too many sigops")]
178189
TooManySigops,
179190
#[error(

lib/state/mod.rs

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ use crate::{
1818
Accumulator, Address, AmountOverflowError, AmountUnderflowError,
1919
Authorized, AuthorizedTransaction, BlockHash, Body, FilledTransaction,
2020
GetAddress, GetValue, Header, InPoint, M6id, MerkleRoot, OutPoint,
21-
OutPointKey, Output, PointedOutput, SpentOutput, Transaction, VERSION,
22-
Verify, Version, WithdrawalBundle, WithdrawalBundleStatus,
23-
proto::mainchain::TwoWayPegData,
21+
OutPointKey, Output, PointedOutput, PointedOutputRef, SpentOutput,
22+
Transaction, VERSION, Verify, Version, WithdrawalBundle,
23+
WithdrawalBundleStatus, proto::mainchain::TwoWayPegData,
2424
},
2525
util::Watchable,
2626
};
@@ -332,10 +332,30 @@ impl State {
332332
Ok(Some((bundle, height)))
333333
}
334334

335+
fn validate_utxo_hashes(
336+
transaction: &FilledTransaction,
337+
) -> Result<(), Error> {
338+
for (outpoint, utxo_hash, output) in transaction.inputs() {
339+
let outpoint = *outpoint;
340+
let utxo_hash = *utxo_hash;
341+
let computed_utxo_hash =
342+
crate::types::hash(&PointedOutputRef { outpoint, output });
343+
if utxo_hash != computed_utxo_hash {
344+
return Err(Error::UtxoHashMismatch {
345+
computed: computed_utxo_hash,
346+
outpoint,
347+
input_hash: utxo_hash,
348+
});
349+
}
350+
}
351+
Ok(())
352+
}
353+
335354
pub fn validate_filled_transaction(
336355
&self,
337356
transaction: &FilledTransaction,
338357
) -> Result<bitcoin::Amount, Error> {
358+
let () = Self::validate_utxo_hashes(transaction)?;
339359
let mut value_in = bitcoin::Amount::ZERO;
340360
let mut value_out = bitcoin::Amount::ZERO;
341361
for utxo in &transaction.spent_utxos {
@@ -579,44 +599,47 @@ mod test {
579599
},
580600
};
581601

582-
fn temp_env_path(test_name: &str) -> anyhow::Result<std::path::PathBuf> {
602+
pub fn temp_env_path(
603+
test_name: &str,
604+
) -> anyhow::Result<std::path::PathBuf> {
583605
let mut path = std::env::temp_dir();
584606
let nanos = std::time::SystemTime::now()
585607
.duration_since(std::time::UNIX_EPOCH)?
586608
.as_nanos();
587-
path.push(format!(
588-
"photon-{test_name}-{}-{nanos}",
589-
std::process::id()
590-
));
609+
path.push(format!("photon-{test_name}-{}-{nanos}", std::process::id()));
591610
Ok(path)
592611
}
593612

594613
// open a fresh state-backed env in a unique temp dir
595-
fn temp_env(test_name: &str) -> anyhow::Result<sneed::Env> {
614+
pub fn temp_env(test_name: &str) -> anyhow::Result<sneed::Env> {
596615
let path = temp_env_path(test_name)?;
597616
std::fs::create_dir_all(&path)?;
598617
let mut opts = heed::EnvOpenOptions::new();
599-
opts.map_size(16 * 1024 * 1024).max_dbs(State::NUM_DBS);
618+
opts.map_size(64 * 1024 * 1024).max_dbs(State::NUM_DBS);
600619
let res = unsafe { sneed::Env::open(&opts, &path) }?;
601620
Ok(res)
602621
}
603622

604-
fn fresh_state(test_name: &str) -> anyhow::Result<(sneed::Env, State)> {
623+
pub fn fresh_state(test_name: &str) -> anyhow::Result<(sneed::Env, State)> {
605624
let env = temp_env(test_name)?;
606625
let state = State::new(&env)?;
607626
Ok((env, state))
608627
}
609628

629+
/// Create a value output
630+
pub fn value_output(addr: Address, sats: u64) -> Output {
631+
Output {
632+
address: addr,
633+
content: OutputContent::Value(bitcoin::Amount::from_sat(sats)),
634+
}
635+
}
636+
610637
#[test]
611638
fn sidechain_wealth() -> anyhow::Result<()> {
612639
use std::str::FromStr;
613640

614641
use bitcoin::hashes::Hash as _;
615642

616-
let value_output = |sats: u64| Output {
617-
address: Address::ALL_ZEROS,
618-
content: OutputContent::Value(bitcoin::Amount::from_sat(sats)),
619-
};
620643
let (env, state) = fresh_state("sidechain-wealth")?;
621644
{
622645
let mut rwtxn = env.write_txn()?;
@@ -631,7 +654,7 @@ mod test {
631654
state.utxos.put(
632655
&mut rwtxn,
633656
&OutPointKey::from(&deposit_utxo_op),
634-
&value_output(50),
657+
&value_output(Address::ALL_ZEROS, 50),
635658
)?;
636659

637660
// Two spent DEPOSIT STXOs: 100 + 100 sats.
@@ -641,7 +664,7 @@ mod test {
641664
vout: 0,
642665
});
643666
let stxo = SpentOutput {
644-
output: value_output(sats),
667+
output: value_output(Address::ALL_ZEROS, sats),
645668
inpoint: InPoint::Regular {
646669
txid: [i; 32].into(),
647670
vin: 0,
@@ -659,7 +682,7 @@ mod test {
659682
vout: 0,
660683
};
661684
let stxo = SpentOutput {
662-
output: value_output(sats),
685+
output: value_output(Address::ALL_ZEROS, sats),
663686
inpoint: InPoint::Withdrawal {
664687
m6id: crate::types::M6id(
665688
bitcoin::Txid::from_byte_array([i; 32]),

0 commit comments

Comments
 (0)