diff --git a/.tool-versions b/.tool-versions index ab54af5d..cc60fd62 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -scarb 2.8.0 +scarb 2.8.2 diff --git a/packages/client/src/main.cairo b/packages/client/src/main.cairo index 34c65fc2..61d1fa84 100644 --- a/packages/client/src/main.cairo +++ b/packages/client/src/main.cairo @@ -1,26 +1,36 @@ use consensus::types::block::Block; -use consensus::types::chain_state::{ChainState, BlockValidator}; +use consensus::types::state::State; +use consensus::types::chain_state::BlockValidator; +use consensus::types::utxo_set::UtxoSet; /// Raito program arguments. #[derive(Serde)] struct Args { - /// Current (initial) chain state - chain_state: ChainState, + /// Current (initial) state + state: State, /// Batch of blocks that have to be applied to the current chain state blocks: Array, } /// Raito program entrypoint. /// -/// Receives current chain state and pending blocks, +/// Receives current state (chain state + utreexo state) and pending blocks, /// then validates and applies them one by one. -/// Returns new chain state in case of succes, otherwise raises an error. -fn main(mut arguments: Span) -> ChainState { - let Args { mut chain_state, blocks, } = Serde::deserialize(ref arguments) +/// Returns new state in case of succes, otherwise raises an error. +fn main(mut arguments: Span) -> State { + let Args { mut state, blocks, } = Serde::deserialize(ref arguments) .expect('Failed to deserialize'); + let mut utxo_set = UtxoSet { utreexo_state: state.utreexo_state, cache: Default::default(), }; + for block in blocks { - chain_state = chain_state.validate_and_apply(block).expect('Validation failed'); + state + .chain_state = state + .chain_state + .validate_and_apply(block, ref utxo_set) + .expect('Validation failed'); }; - chain_state + + state.utreexo_state = utxo_set.utreexo_state; + state } diff --git a/packages/client/src/test.cairo b/packages/client/src/test.cairo index a8d03acf..8d38979e 100644 --- a/packages/client/src/test.cairo +++ b/packages/client/src/test.cairo @@ -1,5 +1,7 @@ use consensus::types::block::Block; -use consensus::types::chain_state::{ChainState, BlockValidator}; +use consensus::types::chain_state::{ChainState, BlockValidatorImpl}; +use consensus::types::state::{State}; +use consensus::types::utxo_set::UtxoSet; use core::testing::get_available_gas; /// Integration testing program arguments. @@ -22,13 +24,20 @@ fn test(mut arguments: Span) { let Args { mut chain_state, blocks, expected_chain_state } = Serde::deserialize(ref arguments) .expect('Failed to deserialize'); + // Temporary solution while script doesn't handle utreexo. + // Allows to test one isolated block, or a batch of blocks starting from genesis. + let mut state: State = State { chain_state: chain_state, utreexo_state: Default::default(), }; + let mut utxo_set: UtxoSet = UtxoSet { + utreexo_state: state.utreexo_state, cache: Default::default() + }; + let mut gas_before = get_available_gas(); for block in blocks { - let height = chain_state.block_height + 1; - match chain_state.validate_and_apply(block) { + let height = state.chain_state.block_height + 1; + match state.chain_state.validate_and_apply(block, ref utxo_set) { Result::Ok(new_chain_state) => { - chain_state = new_chain_state; + state.chain_state = new_chain_state; let gas_after = get_available_gas(); println!("OK: block={} gas_spent={}", height, gas_before - gas_after); gas_before = gas_after; @@ -43,12 +52,12 @@ fn test(mut arguments: Span) { } }; - if chain_state != expected_chain_state { + if state.chain_state != expected_chain_state { println!( "FAIL: block={} error='expected state {:?}, actual {:?}'", - chain_state.block_height, + state.chain_state.block_height, expected_chain_state, - chain_state + state.chain_state ); panic!(); } diff --git a/packages/consensus/src/lib.cairo b/packages/consensus/src/lib.cairo index 238e5741..639285f1 100644 --- a/packages/consensus/src/lib.cairo +++ b/packages/consensus/src/lib.cairo @@ -14,4 +14,5 @@ pub mod types { pub mod block; pub mod transaction; pub mod utxo_set; + pub mod state; } diff --git a/packages/consensus/src/types/chain_state.cairo b/packages/consensus/src/types/chain_state.cairo index 37b01eee..05881929 100644 --- a/packages/consensus/src/types/chain_state.cairo +++ b/packages/consensus/src/types/chain_state.cairo @@ -4,14 +4,15 @@ //! Chain state alone is not enough to do full block validation, however //! it is sufficient to validate block headers. -use utils::hash::Digest; +use core::fmt::{Display, Formatter, Error}; use crate::validation::{ difficulty::{validate_bits, adjust_difficulty}, coinbase::validate_coinbase, timestamp::{validate_timestamp, next_prev_timestamps}, work::{validate_proof_of_work, compute_total_work}, block::{compute_and_validate_tx_data}, }; use super::block::{BlockHash, Block, TransactionData}; -use core::fmt::{Display, Formatter, Error}; +use super::utxo_set::UtxoSet; +use utils::hash::Digest; /// Represents the state of the blockchain. #[derive(Drop, Copy, Debug, PartialEq, Serde)] @@ -55,7 +56,9 @@ impl ChainStateDefault of Default { /// Full block validator (w/o bitcoin script checks and utxo inclusion verification for now). #[generate_trait] pub impl BlockValidatorImpl of BlockValidator { - fn validate_and_apply(self: ChainState, block: Block) -> Result { + fn validate_and_apply( + self: ChainState, block: Block, ref utxo_set: UtxoSet + ) -> Result { let block_height = self.block_height + 1; validate_timestamp(self.prev_timestamps, block.header.time)?; @@ -66,7 +69,7 @@ pub impl BlockValidatorImpl of BlockValidator { TransactionData::MerkleRoot(root) => root, TransactionData::Transactions(txs) => { let (total_fees, txid_root, wtxid_root) = compute_and_validate_tx_data( - txs, block_height, block.header.time + txs, block_height, block.header.time, ref utxo_set )?; validate_coinbase(txs[0], total_fees, block_height, wtxid_root)?; txid_root diff --git a/packages/consensus/src/types/state.cairo b/packages/consensus/src/types/state.cairo new file mode 100644 index 00000000..80160f3f --- /dev/null +++ b/packages/consensus/src/types/state.cairo @@ -0,0 +1,10 @@ +//! State is a top level struct containing the chain state and the utxo set + +use crate::types::utreexo::{UtreexoState, UtreexoStateDefault}; +use super::chain_state::ChainState; + +#[derive(Default, Drop, Copy, Debug, Serde)] +pub struct State { + pub chain_state: ChainState, + pub utreexo_state: UtreexoState +} diff --git a/packages/consensus/src/types/utxo_set.cairo b/packages/consensus/src/types/utxo_set.cairo index e91f7480..ad7b2db4 100644 --- a/packages/consensus/src/types/utxo_set.cairo +++ b/packages/consensus/src/types/utxo_set.cairo @@ -10,17 +10,19 @@ //! Utreexo accumulator or local cache. use core::dict::Felt252Dict; +use core::hash::{HashStateTrait, HashStateExTrait}; +use core::poseidon::PoseidonTrait; use super::utreexo::UtreexoState; use super::transaction::OutPoint; -#[derive(Default)] +#[derive(Default, Destruct)] pub struct UtxoSet { /// Utreexo state. - utreexo_state: UtreexoState, + pub utreexo_state: UtreexoState, /// Hashes of UTXOs created within the current block(s). /// Note that to preserve the ordering, cache has to be updated right after a /// particular output is created or spent. - cache: Felt252Dict<()>, + pub cache: Felt252Dict, } #[generate_trait] @@ -30,15 +32,21 @@ pub impl UtxoSetImpl of UtxoSetTrait { } fn add(ref self: UtxoSet, output: OutPoint) { - if output.data.cached { // TODO: add to the local block cache + if output.data.cached { + let outpoint_hash = PoseidonTrait::new().update_with(output).finalize(); + self.cache.insert(outpoint_hash, true); } else { // TODO: update utreexo roots } } fn delete(ref self: UtxoSet, output: @OutPoint) { - if *output.data.cached { // TODO: remove from cache (+ verify inclusion) + if *output.data.cached { + let outpoint_hash = PoseidonTrait::new().update_with(*output).finalize(); + // Extra check that can be removed later. + assert(self.cache.get(outpoint_hash), 'output is not cached'); + self.cache.insert(outpoint_hash, false); } else { // TODO: update utreexo roots (+ verify inclusion) - // If batched proofs are used then do nothing + // If batched proofs are used then do nothing. } } } diff --git a/packages/consensus/src/validation/block.cairo b/packages/consensus/src/validation/block.cairo index c77ec35e..0ec9bc21 100644 --- a/packages/consensus/src/validation/block.cairo +++ b/packages/consensus/src/validation/block.cairo @@ -1,5 +1,6 @@ //! Block validation helpers. -use crate::types::transaction::{Transaction}; +use crate::types::utxo_set::UtxoSet; +use crate::types::transaction::Transaction; use crate::codec::{Encode, TransactionCodec}; use utils::{hash::Digest, merkle_tree::merkle_root, sha256::double_sha256_byte_array}; use super::transaction::validate_transaction; @@ -27,7 +28,7 @@ pub fn validate_block_weight(weight: usize) -> Result<(), ByteArray> { /// - wTXID commitment (only for blocks after Segwit upgrade, otherwise return zero hash) /// - Block weight pub fn compute_and_validate_tx_data( - txs: Span, block_height: u32, block_time: u32 + txs: Span, block_height: u32, block_time: u32, ref utxo_set: UtxoSet ) -> Result<(u64, Digest, Digest), ByteArray> { let mut txids: Array = array![]; let mut wtxids: Array = array![]; @@ -66,12 +67,13 @@ pub fn compute_and_validate_tx_data( // skipping the coinbase transaction if (i != 0) { - let fee = match validate_transaction(tx, block_height, block_time) { + let fee = match validate_transaction(tx, block_height, block_time, txid, ref utxo_set) { Result::Ok(fee) => fee, Result::Err(err) => { break Result::Err(err); } }; total_fee += fee; } + i += 1; }; validate_transactions?; diff --git a/packages/consensus/src/validation/transaction.cairo b/packages/consensus/src/validation/transaction.cairo index f8bba557..868b965b 100644 --- a/packages/consensus/src/validation/transaction.cairo +++ b/packages/consensus/src/validation/transaction.cairo @@ -1,15 +1,17 @@ //! Transaction validation helpers. -use crate::types::transaction::Transaction; +use crate::types::transaction::{OutPoint, Transaction, TxOut}; +use crate::types::utxo_set::{UtxoSet, UtxoSetTrait}; use crate::validation::locktime::{ is_input_final, validate_absolute_locktime, validate_relative_locktime }; +use utils::hash::Digest; /// Validate transaction and return transaction fee. /// /// This does not include script checks and outpoint inclusion verification. pub fn validate_transaction( - tx: @Transaction, block_height: u32, block_time: u32 + tx: @Transaction, block_height: u32, block_time: u32, txid: Digest, ref utxo_set: UtxoSet ) -> Result { if (*tx.inputs).is_empty() { return Result::Err("transaction inputs are empty"); @@ -29,6 +31,10 @@ pub fn validate_transaction( for input in *tx .inputs { + // Removes the outpoint hash of a transaction input if it was in the cache. + let outpoint = input.previous_output; + utxo_set.delete(outpoint); + if *input.previous_output.is_coinbase { inner_result = validate_coinbase_maturity(*input.previous_output.block_height, block_height); @@ -60,9 +66,29 @@ pub fn validate_transaction( // Validate and process transaction outputs let mut total_output_amount = 0; - for output in *tx.outputs { - total_output_amount += *output.value; - }; + let mut vout = 1; + for output in *tx + .outputs { + // Adds outpoint hash in the cache if the corresponding transaction output will be used + // as a transaction input in the same block(s). + if (*output.cached) { + let outpoint = OutPoint { + txid: txid, + vout: vout, + data: TxOut { + value: *output.value, pk_script: *output.pk_script, cached: true + }, + block_height: block_height, + block_time: block_time, + is_coinbase: false, + }; + + utxo_set.add(outpoint); + } + + total_output_amount += *output.value; + vout += 1; + }; return compute_transaction_fee(total_input_amount, total_output_amount); } @@ -96,8 +122,13 @@ fn validate_coinbase_maturity(output_height: u32, block_height: u32) -> Result<( #[cfg(test)] mod tests { + use core::dict::Felt252Dict; + use core::hash::{HashStateTrait, HashStateExTrait}; + use core::poseidon::PoseidonTrait; + use crate::codec::Encode; use crate::types::transaction::{Transaction, TxIn, TxOut, OutPoint}; - use utils::hex::{from_hex, hex_to_hash_rev}; + use crate::types::utxo_set::UtxoSet; + use utils::{hex::{from_hex, hex_to_hash_rev}, sha256::double_sha256_byte_array}; use super::validate_transaction; // TODO: tests for coinbase maturity @@ -139,9 +170,14 @@ mod tests { .span(), lock_time: 0 }; - assert!(validate_transaction(@tx, 0, 0).is_err()); - let fee = validate_transaction(@tx, 101, 0).unwrap(); + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + assert!(validate_transaction(@tx, 0, 0, txid, ref utxo_set).is_err()); + + let fee = validate_transaction(@tx, 101, 0, txid, ref utxo_set).unwrap(); assert_eq!(fee, 10); } @@ -162,7 +198,11 @@ mod tests { lock_time: 0 }; - let result = validate_transaction(@tx, 0, 0); + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + let result = validate_transaction(@tx, 0, 0, txid, ref utxo_set); assert!(result.is_err()); // assert_eq!(result.unwrap_err(), "transaction inputs are empty"); } @@ -194,7 +234,11 @@ mod tests { lock_time: 0 }; - let result = validate_transaction(@tx, 0, 0); + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + let result = validate_transaction(@tx, 0, 0, txid, ref utxo_set); assert!(result.is_err()); // assert_eq!(result.unwrap_err(), "transaction outputs are empty"); } @@ -233,8 +277,12 @@ mod tests { lock_time: 500000 // Block height locktime }; + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + // Transaction should be invalid when current block height is less than locktime - let result = validate_transaction(@tx, 500000, 0); + let result = validate_transaction(@tx, 500000, 0, txid, ref utxo_set); assert!(result.is_err()); assert_eq!( result.unwrap_err().into(), @@ -243,7 +291,7 @@ mod tests { // Transaction should be valid when current block height is equal to or greater than // locktime - let result = validate_transaction(@tx, 500001, 0); + let result = validate_transaction(@tx, 500001, 0, txid, ref utxo_set); assert!(result.is_ok()); } @@ -281,8 +329,12 @@ mod tests { lock_time: 1600000000 // UNIX timestamp locktime }; + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + // Transaction should be invalid when current block time is not greater than locktime - let result = validate_transaction(@tx, 0, 1600000000); + let result = validate_transaction(@tx, 0, 1600000000, txid, ref utxo_set); assert!(result.is_err()); assert_eq!( result.unwrap_err().into(), @@ -290,7 +342,7 @@ mod tests { ); // Transaction should be valid when current block time is equal to or greater than locktime - let result = validate_transaction(@tx, 0, 1600000001); + let result = validate_transaction(@tx, 0, 1600000001, txid, ref utxo_set); assert!(result.is_ok()); } @@ -328,12 +380,16 @@ mod tests { lock_time: 1600000000 // UNIX timestamp locktime }; + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + // Transaction should still valid when current block time is not greater than locktime - let result = validate_transaction(@tx, 0, 1600000000); + let result = validate_transaction(@tx, 0, 1600000000, txid, ref utxo_set); assert!(result.is_ok()); // Transaction should be valid when current block time is greater than locktime - let result = validate_transaction(@tx, 0, 1600000001); + let result = validate_transaction(@tx, 0, 1600000001, txid, ref utxo_set); assert!(result.is_ok()); } @@ -371,12 +427,16 @@ mod tests { lock_time: 500000 // Block height locktime }; + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + // Transaction should still valid when current block time is not greater than locktime - let result = validate_transaction(@tx, 500000, 0); + let result = validate_transaction(@tx, 500000, 0, txid, ref utxo_set); assert!(result.is_ok()); // Transaction should be valid when current block time is greater than locktime - let result = validate_transaction(@tx, 500001, 0); + let result = validate_transaction(@tx, 500001, 0, txid, ref utxo_set); assert!(result.is_ok()); } @@ -416,7 +476,11 @@ mod tests { lock_time: 0 }; - validate_transaction(@tx, block_height, 0).unwrap_err(); + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + validate_transaction(@tx, block_height, 0, txid, ref utxo_set).unwrap_err(); } #[test] @@ -455,6 +519,103 @@ mod tests { lock_time: 0 }; - validate_transaction(@tx, block_height, 0).unwrap(); + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + validate_transaction(@tx, block_height, 0, txid, ref utxo_set).unwrap(); + } + + #[test] + #[should_panic(expected: 'output is not cached')] + fn test_uncached_utxo_spending_attempt() { + let block_height = 150; + + let tx = Transaction { + version: 1, + is_segwit: false, + inputs: array![ + TxIn { + script: @from_hex(""), + sequence: 0xfffffffe, + previous_output: OutPoint { + txid: hex_to_hash_rev( + "0000000000000000000000000000000000000000000000000000000000000000" + ), + vout: 0, + data: TxOut { value: 100, pk_script: @from_hex(""), cached: true }, + block_height: Default::default(), + block_time: Default::default(), + is_coinbase: false, + }, + witness: array![].span(), + } + ] + .span(), + outputs: array![ + TxOut { + value: 50, + pk_script: @from_hex("76a914000000000000000000000000000000000000000088ac"), + cached: false, + } + ] + .span(), + lock_time: 0 + }; + + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + let mut utxo_set: UtxoSet = Default::default(); + + validate_transaction(@tx, block_height, 0, txid, ref utxo_set).unwrap(); + } + + #[test] + fn test_cached_utxo_spending_attempt() { + let block_height = 150; + + let tx = Transaction { + version: 1, + is_segwit: false, + inputs: array![ + TxIn { + script: @from_hex(""), + sequence: 0xfffffffe, + previous_output: OutPoint { + txid: hex_to_hash_rev( + "0000000000000000000000000000000000000000000000000000000000000000" + ), + vout: 0, + data: TxOut { value: 100, pk_script: @from_hex(""), cached: true }, + block_height: Default::default(), + block_time: Default::default(), + is_coinbase: false, + }, + witness: array![].span(), + } + ] + .span(), + outputs: array![ + TxOut { + value: 50, + pk_script: @from_hex("76a914000000000000000000000000000000000000000088ac"), + cached: false, + } + ] + .span(), + lock_time: 0 + }; + + let tx_bytes_legacy = @tx.encode(); + let txid = double_sha256_byte_array(tx_bytes_legacy); + + let mut cache: Felt252Dict = Default::default(); + let outpoint_hash = PoseidonTrait::new() + .update_with((*tx.inputs[0]).previous_output) + .finalize(); + cache.insert(outpoint_hash, true); + let mut utxo_set: UtxoSet = UtxoSet { utreexo_state: Default::default(), cache: cache, }; + + validate_transaction(@tx, block_height, 0, txid, ref utxo_set).unwrap(); } }