From 88e81a7e31353b40843ae44c7bb492a9c0952c3d Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 5 Dec 2025 10:51:22 +0100 Subject: [PATCH 01/13] feat: add error types --- common/src/types.rs | 29 ++++++- common/src/validation.rs | 81 +++++++++++++++++-- .../src/indices/fjall_pool_cost_index.rs | 9 +-- .../src/indices/in_memory_pool_cost_index.rs | 9 +-- 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/common/src/types.rs b/common/src/types.rs index 9c451492..fb7b99a6 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -764,7 +764,7 @@ impl fmt::Display for UTxOIdentifier { } } -// Full TxOutRef stored in UTxORegistry for UTxOIdentifier lookups +/// Full TxOutRef stored in UTxORegistry for UTxOIdentifier lookups #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct TxOutRef { pub tx_hash: TxHash, @@ -786,6 +786,33 @@ impl Display for TxOutRef { } } +pub type VKey = Vec; +pub type Signature = Vec; + +/// VKey Witness +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct VKeyWitness { + pub vkey: VKey, + pub signature: Signature, +} + +impl VKeyWitness { + pub fn new(vkey: VKey, signature: Signature) -> Self { + Self { vkey, signature } + } +} + +impl Display for VKeyWitness { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "vkey={}, signature={}", + hex::encode(self.vkey.clone()), + hex::encode(self.signature.clone()) + ) + } +} + /// Slot pub type Slot = u64; diff --git a/common/src/validation.rs b/common/src/validation.rs index d10b8a13..bfba56cf 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -8,8 +8,9 @@ use std::array::TryFromSliceError; use thiserror::Error; use crate::{ - protocol_params::Nonce, rational_number::RationalNumber, Address, Era, GenesisKeyhash, - Lovelace, NetworkId, PoolId, Slot, StakeAddress, TxOutRef, Value, VrfKeyHash, + protocol_params::Nonce, rational_number::RationalNumber, Address, DataHash, Era, + GenesisKeyhash, Lovelace, NetworkId, PoolId, ScriptHash, Slot, StakeAddress, TxOutRef, VKey, + VKeyWitness, Value, VrfKeyHash, }; /// Transaction Validation Error @@ -32,11 +33,11 @@ pub enum TransactionValidationError { Other(String), } -/// UTxO rules failure -/// Shelley Era Errors: +/// UTxO Rules Failure +/// Shelley Era: /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxo.hs#L343 /// -/// Allegra Era Errors: +/// Allegra Era: /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/allegra/impl/src/Cardano/Ledger/Allegra/Rules/Utxo.hs#L160 #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error, PartialEq, Eq)] pub enum UTxOValidationError { @@ -109,6 +110,76 @@ pub enum UTxOValidationError { MalformedUTxO { era: Era, reason: String }, } +/// UTxOW Rules Failure +/// Shelley Era: +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L278 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error, PartialEq, Eq)] +pub enum UTxOWValidationError { + /// **Cause:** The VKey witness has invalid signature + #[error("Invalid VKey witness: witness={witness}, reason={reason}")] + InvalidWitnessesUTxOW { + witness: VKeyWitness, + reason: String, + }, + + /// **Cause:** Required VKey witness missing + #[error("Missing VKey witness: vkey={}", hex::encode(vkey))] + MissingVKeyWitnessesUTxOW { vkey: VKey }, + + /// **Cause:** Required script witness missing + #[error("Missing script witness: script={script}")] + MissingScriptWitnessesUTxOW { script: ScriptHash }, + + /// **Cause:** Native script validation failed + #[error("Native script validation failed: script={script}")] + ScriptWitnessNotValidatingUTXOW { script: ScriptHash }, + + /// **Cause:** Extraneous script witness is provided + #[error("Script provided but not used: script={script}")] + ExtraneousScriptWitnessesUTXOW { script: ScriptHash }, + + /// **Cause:** Insufficient genesis signatures for MIR Tx + #[error( + "Insufficient Genesis Signatures for MIR: gensis_keys={}, count={}", + gensis_keys.iter().map(hex::encode).collect::>().join(","), + gensis_keys.len() + )] + MIRInsufficientGenesisSigsUTXOW { gensis_keys: Vec }, + + /// **Cause:** Metadata without metadata hash + #[error( + "Metadata without metadata hash: full_hash={}", + hex::encode(metadata_hash) + )] + MissingTxBodyMetadataHash { metadata_hash: DataHash }, + + /// **Cause:** Metadata hash mismatch + #[error( + "Metadata hash mismatch: expected={}, actual={}", + hex::encode(expected), + hex::encode(actual) + )] + ConflictingMetadataHash { + expected: DataHash, + actual: DataHash, + }, + + /// **Cause:** Invalid metadata + /// metadata - bytes, text - size (0..64) + #[error("Invalid metadata: reason={reason}")] + InvalidMetadata { reason: String }, + + /// **Cause:** Metadata hash without actual metadata + #[error( + "Metadata hash without actual metadata: hash={}", + hex::encode(metadata_hash) + )] + MissingTxMetadata { + // hash of metadata included in tx body + metadata_hash: DataHash, + }, +} + /// Validation error #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error)] pub enum ValidationError { diff --git a/processes/indexer/src/indices/fjall_pool_cost_index.rs b/processes/indexer/src/indices/fjall_pool_cost_index.rs index 364c6208..a1d52678 100644 --- a/processes/indexer/src/indices/fjall_pool_cost_index.rs +++ b/processes/indexer/src/indices/fjall_pool_cost_index.rs @@ -1,5 +1,4 @@ #![allow(unused)] -use acropolis_codec::map_parameters::to_pool_id; use acropolis_common::{BlockInfo, Lovelace, PoolId}; use acropolis_module_custom_indexer::chain_index::ChainIndex; use anyhow::Result; @@ -58,7 +57,7 @@ impl ChainIndex for FjallPoolCostIndex { match cert { MultiEraCert::AlonzoCompatible(cert) => match cert.as_ref().as_ref() { alonzo::Certificate::PoolRegistration { operator, cost, .. } => { - let pool_id = to_pool_id(operator); + let pool_id = acropolis_codec::to_pool_id(operator); let key = pool_id.as_ref(); let value = bincode::serialize(cost)?; @@ -70,7 +69,7 @@ impl ChainIndex for FjallPoolCostIndex { } } alonzo::Certificate::PoolRetirement(operator, ..) => { - let pool_id = to_pool_id(operator); + let pool_id = acropolis_codec::to_pool_id(operator); let key = pool_id.as_ref(); self.state.pools.remove(&pool_id); @@ -85,7 +84,7 @@ impl ChainIndex for FjallPoolCostIndex { }, MultiEraCert::Conway(cert) => match cert.as_ref().as_ref() { conway::Certificate::PoolRegistration { operator, cost, .. } => { - let pool_id = to_pool_id(operator); + let pool_id = acropolis_codec::to_pool_id(operator); let key = pool_id.as_ref(); let value = bincode::serialize(cost)?; @@ -97,7 +96,7 @@ impl ChainIndex for FjallPoolCostIndex { } } conway::Certificate::PoolRetirement(operator, ..) => { - let pool_id = to_pool_id(operator); + let pool_id = acropolis_codec::to_pool_id(operator); let key = pool_id.as_ref(); self.state.pools.remove(&pool_id); diff --git a/processes/indexer/src/indices/in_memory_pool_cost_index.rs b/processes/indexer/src/indices/in_memory_pool_cost_index.rs index 5dcca442..6f5cff6a 100644 --- a/processes/indexer/src/indices/in_memory_pool_cost_index.rs +++ b/processes/indexer/src/indices/in_memory_pool_cost_index.rs @@ -1,5 +1,4 @@ #![allow(unused)] -use acropolis_codec::map_parameters::to_pool_id; use acropolis_common::{BlockInfo, Lovelace, PoolId}; use acropolis_module_custom_indexer::chain_index::ChainIndex; use anyhow::Result; @@ -42,13 +41,13 @@ impl ChainIndex for InMemoryPoolCostIndex { match cert { MultiEraCert::AlonzoCompatible(cert) => match cert.as_ref().as_ref() { alonzo::Certificate::PoolRegistration { operator, cost, .. } => { - self.state.pools.insert(to_pool_id(operator), *cost); + self.state.pools.insert(acropolis_codec::to_pool_id(operator), *cost); if self.sender.send(self.state.clone()).is_err() { warn!("Pool cost state receiver dropped"); } } alonzo::Certificate::PoolRetirement(operator, ..) => { - self.state.pools.remove(&to_pool_id(operator)); + self.state.pools.remove(&acropolis_codec::to_pool_id(operator)); if self.sender.send(self.state.clone()).is_err() { warn!("Pool cost state receiver dropped"); } @@ -58,13 +57,13 @@ impl ChainIndex for InMemoryPoolCostIndex { }, MultiEraCert::Conway(cert) => match cert.as_ref().as_ref() { conway::Certificate::PoolRegistration { operator, cost, .. } => { - self.state.pools.insert(to_pool_id(operator), *cost); + self.state.pools.insert(acropolis_codec::to_pool_id(operator), *cost); if self.sender.send(self.state.clone()).is_err() { warn!("Pool cost state receiver dropped"); } } conway::Certificate::PoolRetirement(operator, ..) => { - self.state.pools.remove(&to_pool_id(operator)); + self.state.pools.remove(&acropolis_codec::to_pool_id(operator)); if self.sender.send(self.state.clone()).is_err() { warn!("Pool cost state receiver dropped"); } From 50fe09d86c621914d76e8e044aafe92a267fd148 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 5 Dec 2025 15:12:14 +0100 Subject: [PATCH 02/13] wip: utxo look up --- Cargo.lock | 1 + modules/utxo_state/Cargo.toml | 1 + modules/utxo_state/src/utxo_state.rs | 2 + modules/utxo_state/src/validations/mod.rs | 1 + .../utxo_state/src/validations/shelley/mod.rs | 1 + .../src/validations/shelley/utxow.rs | 39 +++++++++++++++++++ 6 files changed, 45 insertions(+) create mode 100644 modules/utxo_state/src/validations/mod.rs create mode 100644 modules/utxo_state/src/validations/shelley/mod.rs create mode 100644 modules/utxo_state/src/validations/shelley/utxow.rs diff --git a/Cargo.lock b/Cargo.lock index dee8c9ce..23749fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,6 +512,7 @@ dependencies = [ "config", "dashmap", "fjall", + "pallas 0.33.0", "serde_cbor", "sled", "tokio", diff --git a/modules/utxo_state/Cargo.toml b/modules/utxo_state/Cargo.toml index d425bbaa..62cbe952 100644 --- a/modules/utxo_state/Cargo.toml +++ b/modules/utxo_state/Cargo.toml @@ -22,6 +22,7 @@ serde_cbor = "0.11.2" sled = "0.34.7" tokio = { workspace = true } tracing = { workspace = true } +pallas = { workspace = true } [lib] path = "src/utxo_state.rs" diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index c5f39853..7668b4ac 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -35,6 +35,8 @@ use fjall_async_immutable_utxo_store::FjallAsyncImmutableUTXOStore; mod fake_immutable_utxo_store; use fake_immutable_utxo_store::FakeImmutableUTXOStore; +mod validations; + const DEFAULT_SUBSCRIBE_TOPIC: &str = "cardano.utxo.deltas"; const DEFAULT_STORE: &str = "memory"; diff --git a/modules/utxo_state/src/validations/mod.rs b/modules/utxo_state/src/validations/mod.rs new file mode 100644 index 00000000..0acc2831 --- /dev/null +++ b/modules/utxo_state/src/validations/mod.rs @@ -0,0 +1 @@ +mod shelley; diff --git a/modules/utxo_state/src/validations/shelley/mod.rs b/modules/utxo_state/src/validations/shelley/mod.rs new file mode 100644 index 00000000..4359b4e7 --- /dev/null +++ b/modules/utxo_state/src/validations/shelley/mod.rs @@ -0,0 +1 @@ +pub mod utxow; diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs new file mode 100644 index 00000000..745964e4 --- /dev/null +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -0,0 +1,39 @@ +//! Shelley era UTxOW Rules +//! Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L278 + +use acropolis_common::{validation::UTxOWValidationError, UTXOValue, UTxOIdentifier}; +use anyhow::Result; +use pallas::ledger::primitives::alonzo; + +pub fn validate_withnesses( + tx: &alonzo::MintedTx, + lookup_utxo: F, +) -> Result<(), Box> +where + F: Fn(UTxOIdentifier) -> Result>, +{ + for (input_index, input) in tx.transaction_body.inputs.iter().enumerate() { + let utxo_identifier = UTxOIdentifier::new(input. + match lookup_utxo(input) { + Ok(Some(utxo)) => { + if let Some(alonzo_comp_output) = MultiEraOutput::as_alonzo(multi_era_output) { + match get_payment_part(&alonzo_comp_output.address) + .ok_or(ShelleyMA(AddressDecoding))? + { + ShelleyPaymentPart::Key(payment_key_hash) => { + check_vk_wit(&payment_key_hash, tx_hash, vk_wits)? + } + ShelleyPaymentPart::Script(script_hash) => check_native_script_witness( + &script_hash, + &tx_wits + .native_script + .as_ref() + .map(|x| x.iter().map(|y| y.deref().clone()).collect()), + )?, + } + } + } + None => return Err(ShelleyMA(InputNotInUTxO)), + } + } +} From eeff4670bf629fb96f2d19eaf0ff043a8f78b027 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Tue, 9 Dec 2025 14:49:47 +0100 Subject: [PATCH 03/13] feat: add utxow rules --- Cargo.lock | 42 +- codec/src/lib.rs | 14 +- codec/src/utxo.rs | 23 +- common/src/address.rs | 8 + common/src/types.rs | 146 ++++- common/src/validation.rs | 44 +- modules/rest_blockfrost/src/types.rs | 22 +- modules/utxo_state/Cargo.toml | 10 + modules/utxo_state/src/crypto/ed25519.rs | 561 ++++++++++++++++++ modules/utxo_state/src/crypto/mod.rs | 4 + modules/utxo_state/src/crypto/utils.rs | 14 + modules/utxo_state/src/utxo_state.rs | 1 + .../src/validations/shelley/utxow.rs | 382 +++++++++++- 13 files changed, 1206 insertions(+), 65 deletions(-) create mode 100644 modules/utxo_state/src/crypto/ed25519.rs create mode 100644 modules/utxo_state/src/crypto/mod.rs create mode 100644 modules/utxo_state/src/crypto/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 23749fb4..41e95f40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,7 +42,7 @@ dependencies = [ "crc", "cryptoxide 0.5.1", "dashmap", - "env_logger", + "env_logger 0.10.2", "futures", "hex", "log", @@ -505,18 +505,26 @@ dependencies = [ name = "acropolis_module_utxo_state" version = "0.1.0" dependencies = [ + "acropolis_codec", "acropolis_common", "anyhow", "async-trait", "caryatid_sdk", "config", + "cryptoxide 0.5.1", "dashmap", "fjall", + "hex", "pallas 0.33.0", + "quickcheck", + "quickcheck_macros", + "rand_core 0.9.3", "serde_cbor", "sled", + "thiserror 2.0.17", "tokio", "tracing", + "zeroize", ] [[package]] @@ -2423,6 +2431,16 @@ dependencies = [ "syn 2.0.109", ] +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_logger" version = "0.10.2" @@ -5052,6 +5070,28 @@ dependencies = [ "hashbrown 0.16.0", ] +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "env_logger 0.8.4", + "log", + "rand 0.8.5", +] + +[[package]] +name = "quickcheck_macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + [[package]] name = "quinn" version = "0.11.9" diff --git a/codec/src/lib.rs b/codec/src/lib.rs index 88b65133..e4d76629 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -1,10 +1,10 @@ -pub mod address; -pub mod block; -pub mod certs; -pub mod parameter; -pub mod tx; -pub mod utils; -pub mod utxo; +mod address; +mod block; +mod certs; +mod parameter; +mod tx; +mod utils; +mod utxo; pub use address::*; pub use block::*; diff --git a/codec/src/utxo.rs b/codec/src/utxo.rs index fa84df49..bf16e76f 100644 --- a/codec/src/utxo.rs +++ b/codec/src/utxo.rs @@ -1,6 +1,6 @@ use crate::address::map_address; use acropolis_common::{validation::ValidationError, *}; -use pallas_primitives::conway; +use pallas_primitives::{alonzo, conway}; use pallas_traverse::{MultiEraInput, MultiEraPolicyAssets, MultiEraTx, MultiEraValue}; pub fn map_value(pallas_value: &MultiEraValue) -> Value { @@ -84,10 +84,29 @@ pub fn map_datum(datum: &Option) -> Option { } } +pub fn map_native_script(script: &alonzo::NativeScript) -> NativeScript { + match script { + alonzo::NativeScript::ScriptPubkey(addr_key_hash) => { + NativeScript::ScriptPubkey(AddrKeyhash::from(**addr_key_hash)) + } + alonzo::NativeScript::ScriptAll(scripts) => { + NativeScript::ScriptAll(scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::ScriptAny(scripts) => { + NativeScript::ScriptAny(scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::ScriptNOfK(n, scripts) => { + NativeScript::ScriptNOfK(*n, scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::InvalidBefore(slot_no) => NativeScript::InvalidBefore(*slot_no), + alonzo::NativeScript::InvalidHereafter(slot_no) => NativeScript::InvalidHereafter(*slot_no), + } +} + pub fn map_reference_script(script: &Option) -> Option { match script { Some(conway::PseudoScript::NativeScript(script)) => { - Some(ReferenceScript::Native(script.raw_cbor().to_vec())) + Some(ReferenceScript::Native(map_native_script(script))) } Some(conway::PseudoScript::PlutusV1Script(script)) => { Some(ReferenceScript::PlutusV1(script.as_ref().to_vec())) diff --git a/common/src/address.rs b/common/src/address.rs index f770d2c1..855ef0ba 100644 --- a/common/src/address.rs +++ b/common/src/address.rs @@ -635,6 +635,14 @@ impl Address { None } + /// Get payment part + pub fn get_payment_part(&self) -> Option { + if let Address::Shelley(shelley) = self { + return Some(shelley.payment.clone()); + } + None + } + /// Read from string format ("addr1...") pub fn from_string(text: &str) -> Result { if text.starts_with("addr1") || text.starts_with("addr_test1") { diff --git a/common/src/types.rs b/common/src/types.rs index fb7b99a6..24d773bf 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -2,6 +2,7 @@ // We don't use these types in the acropolis_common crate itself #![allow(dead_code)] +use crate::crypto::keyhash_224; use crate::hash::Hash; use crate::serialization::Bech32Conversion; use crate::{ @@ -411,12 +412,135 @@ pub enum Datum { // The full CBOR bytes of a reference script #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub enum ReferenceScript { - Native(Vec), + Native(NativeScript), PlutusV1(Vec), PlutusV2(Vec), PlutusV3(Vec), } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub enum NativeScript { + ScriptPubkey(AddrKeyhash), + ScriptAll(Vec), + ScriptAny(Vec), + ScriptNOfK(u32, Vec), + InvalidBefore(u64), + InvalidHereafter(u64), +} + +impl<'b, C> minicbor::decode::Decode<'b, C> for NativeScript { + fn decode(d: &mut minicbor::Decoder<'b>, ctx: &mut C) -> Result { + let size = d.array()?; + + let assert_size = |expected| { + // NOTE: unwrap_or allows for indefinite arrays. + if expected != size.unwrap_or(expected) { + return Err(minicbor::decode::Error::message( + "unexpected array size in NativeScript", + )); + } + Ok(()) + }; + + let variant = d.u32()?; + + let script = match variant { + 0 => { + assert_size(2)?; + Ok(NativeScript::ScriptPubkey(d.decode_with(ctx)?)) + } + 1 => { + assert_size(2)?; + Ok(NativeScript::ScriptAll(d.decode_with(ctx)?)) + } + 2 => { + assert_size(2)?; + Ok(NativeScript::ScriptAny(d.decode_with(ctx)?)) + } + 3 => { + assert_size(3)?; + Ok(NativeScript::ScriptNOfK( + d.decode_with(ctx)?, + d.decode_with(ctx)?, + )) + } + 4 => { + assert_size(2)?; + Ok(NativeScript::InvalidBefore(d.decode_with(ctx)?)) + } + 5 => { + assert_size(2)?; + Ok(NativeScript::InvalidHereafter(d.decode_with(ctx)?)) + } + _ => Err(minicbor::decode::Error::message( + "unknown variant id for native script", + )), + }?; + + if size.is_none() { + let next = d.datatype()?; + if next != minicbor::data::Type::Break { + return Err(minicbor::decode::Error::type_mismatch(next)); + } + } + + Ok(script) + } +} + +impl minicbor::encode::Encode for NativeScript { + fn encode( + &self, + e: &mut minicbor::Encoder, + ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + match self { + NativeScript::ScriptPubkey(v) => { + e.array(2)?; + e.encode_with(0, ctx)?; + e.encode_with(v, ctx)?; + } + NativeScript::ScriptAll(v) => { + e.array(2)?; + e.encode_with(1, ctx)?; + e.encode_with(v, ctx)?; + } + NativeScript::ScriptAny(v) => { + e.array(2)?; + e.encode_with(2, ctx)?; + e.encode_with(v, ctx)?; + } + NativeScript::ScriptNOfK(a, b) => { + e.array(3)?; + e.encode_with(3, ctx)?; + e.encode_with(a, ctx)?; + e.encode_with(b, ctx)?; + } + NativeScript::InvalidBefore(v) => { + e.array(2)?; + e.encode_with(4, ctx)?; + e.encode_with(v, ctx)?; + } + NativeScript::InvalidHereafter(v) => { + e.array(2)?; + e.encode_with(5, ctx)?; + e.encode_with(v, ctx)?; + } + } + + Ok(()) + } +} + +impl NativeScript { + pub fn compute_hash(&self) -> ScriptHash { + let mut data = vec![0u8]; + let raw_bytes = minicbor::to_vec(self).expect("Failed to encode NativeScript to CBOR"); + data.extend_from_slice(raw_bytes.as_slice()); + ScriptHash::from(keyhash_224(&data)) + } +} + /// Value (lovelace + multiasset) #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)] pub struct Value { @@ -790,7 +914,7 @@ pub type VKey = Vec; pub type Signature = Vec; /// VKey Witness -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct VKeyWitness { pub vkey: VKey, pub signature: Signature, @@ -800,6 +924,10 @@ impl VKeyWitness { pub fn new(vkey: VKey, signature: Signature) -> Self { Self { vkey, signature } } + + pub fn key_hash(&self) -> KeyHash { + keyhash_224(&self.vkey) + } } impl Display for VKeyWitness { @@ -2514,4 +2642,18 @@ mod tests { Ok(()) } + + #[test] + fn resolve_hash_correctly() { + let native_script = NativeScript::ScriptPubkey( + AddrKeyhash::from_str("976ec349c3a14f58959088e13e98f6cd5a1e8f27f6f3160b25e415ca") + .unwrap(), + ); + let script_hash = native_script.compute_hash(); + assert_eq!( + script_hash, + ScriptHash::from_str("c3a33acb8903cf42611e26b15c7731f537867c6469f5bf69c837e4a3") + .unwrap() + ); + } } diff --git a/common/src/validation.rs b/common/src/validation.rs index bfba56cf..3a2aaa31 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -3,13 +3,13 @@ // We don't use these types in the acropolis_common crate itself #![allow(dead_code)] -use std::array::TryFromSliceError; +use std::{array::TryFromSliceError, collections::HashSet}; use thiserror::Error; use crate::{ - protocol_params::Nonce, rational_number::RationalNumber, Address, DataHash, Era, - GenesisKeyhash, Lovelace, NetworkId, PoolId, ScriptHash, Slot, StakeAddress, TxOutRef, VKey, + hash::Hash, protocol_params::Nonce, rational_number::RationalNumber, Address, DataHash, Era, + GenesisKeyhash, KeyHash, Lovelace, NetworkId, PoolId, ScriptHash, Slot, StakeAddress, TxOutRef, VKeyWitness, Value, VrfKeyHash, }; @@ -116,35 +116,39 @@ pub enum UTxOValidationError { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error, PartialEq, Eq)] pub enum UTxOWValidationError { /// **Cause:** The VKey witness has invalid signature - #[error("Invalid VKey witness: witness={witness}, reason={reason}")] + #[error("Invalid VKey witness: key_hash={key_hash}, witness={witness}")] InvalidWitnessesUTxOW { + key_hash: KeyHash, witness: VKeyWitness, - reason: String, }, /// **Cause:** Required VKey witness missing - #[error("Missing VKey witness: vkey={}", hex::encode(vkey))] - MissingVKeyWitnessesUTxOW { vkey: VKey }, + #[error("Missing VKey witness: key_hash={key_hash}")] + MissingVKeyWitnessesUTxOW { key_hash: KeyHash }, /// **Cause:** Required script witness missing - #[error("Missing script witness: script={script}")] - MissingScriptWitnessesUTxOW { script: ScriptHash }, + #[error("Missing script witness: script_hash={script_hash}")] + MissingScriptWitnessesUTxOW { script_hash: ScriptHash }, /// **Cause:** Native script validation failed - #[error("Native script validation failed: script={script}")] - ScriptWitnessNotValidatingUTXOW { script: ScriptHash }, + #[error("Native script validation failed: script_hash={script_hash}")] + ScriptWitnessNotValidatingUTXOW { script_hash: ScriptHash }, /// **Cause:** Extraneous script witness is provided - #[error("Script provided but not used: script={script}")] - ExtraneousScriptWitnessesUTXOW { script: ScriptHash }, + #[error("Script provided but not used: script_hash={script_hash}")] + ExtraneousScriptWitnessesUTXOW { script_hash: ScriptHash }, /// **Cause:** Insufficient genesis signatures for MIR Tx #[error( - "Insufficient Genesis Signatures for MIR: gensis_keys={}, count={}", - gensis_keys.iter().map(hex::encode).collect::>().join(","), - gensis_keys.len() + "Insufficient Genesis Signatures for MIR: gensis_keys={}, count={}, quorum={}", + gensis_keys.iter().map(|k| k.to_string()).collect::>().join(","), + gensis_keys.len(), + quorum )] - MIRInsufficientGenesisSigsUTXOW { gensis_keys: Vec }, + MIRInsufficientGenesisSigsUTXOW { + gensis_keys: HashSet>, + quorum: u32, + }, /// **Cause:** Metadata without metadata hash #[error( @@ -178,6 +182,12 @@ pub enum UTxOWValidationError { // hash of metadata included in tx body metadata_hash: DataHash, }, + + /// **Cause:** Address is malformed + #[error( + "Malformed address: address={}, reason={reason}", address.to_string().unwrap_or("Invalid address".to_string()) + )] + MalformedAddress { address: Address, reason: String }, } /// Validation error diff --git a/modules/rest_blockfrost/src/types.rs b/modules/rest_blockfrost/src/types.rs index fb14391f..78db9c2b 100644 --- a/modules/rest_blockfrost/src/types.rs +++ b/modules/rest_blockfrost/src/types.rs @@ -952,18 +952,16 @@ impl UTxOREST { None => (None, None), }; - let reference_script_hash = entry.reference_script.as_ref().map(|script| { - let bytes = match script { - ReferenceScript::Native(b) - | ReferenceScript::PlutusV1(b) - | ReferenceScript::PlutusV2(b) - | ReferenceScript::PlutusV3(b) => b, - }; - - let mut hasher = Blake2b512::new(); - hasher.update(bytes); - let result = hasher.finalize(); - hex::encode(&result[..32]) + let reference_script_hash = entry.reference_script.as_ref().map(|script| match script { + ReferenceScript::PlutusV1(b) + | ReferenceScript::PlutusV2(b) + | ReferenceScript::PlutusV3(b) => { + let mut hasher = Blake2b512::new(); + hasher.update(b); + let result = hasher.finalize(); + hex::encode(&result[..32]) + } + ReferenceScript::Native(b) => b.compute_hash().to_string(), }); Self { diff --git a/modules/utxo_state/Cargo.toml b/modules/utxo_state/Cargo.toml index 62cbe952..089c5986 100644 --- a/modules/utxo_state/Cargo.toml +++ b/modules/utxo_state/Cargo.toml @@ -10,6 +10,7 @@ license = "Apache-2.0" [dependencies] acropolis_common = { path = "../../common" } +acropolis_codec = { path = "../../codec" } caryatid_sdk = { workspace = true } @@ -23,6 +24,15 @@ sled = "0.34.7" tokio = { workspace = true } tracing = { workspace = true } pallas = { workspace = true } +cryptoxide = "0.5.1" +rand_core = "0.9.3" +thiserror = "2.0.17" +zeroize = "1.8.2" +hex = { workspace = true } + +[dev-dependencies] +quickcheck = "1.0.3" +quickcheck_macros = "1.1.0" [lib] path = "src/utxo_state.rs" diff --git a/modules/utxo_state/src/crypto/ed25519.rs b/modules/utxo_state/src/crypto/ed25519.rs new file mode 100644 index 00000000..3ec1a9d5 --- /dev/null +++ b/modules/utxo_state/src/crypto/ed25519.rs @@ -0,0 +1,561 @@ +//! Ed25519 and Ed25519Extended Asymmetric Keys +//! +//! In this module we have both [`SecretKey`] which is a normal Ed25519 +//! asymmetric key and [`SecretKeyExtended`] asymmetric key. +//! They can both be used to generate [`Signature`] and submit valid +//! transactions. +//! +//! However, only the [`SecretKeyExtended`] can be used for HD derivation +//! (using [ed25519_bip32] or otherwise). + +use cryptoxide::ed25519::{ + self, EXTENDED_KEY_LENGTH, PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, +}; +use rand_core::{CryptoRng, RngCore}; +use std::{any::type_name, convert::TryFrom, fmt, str::FromStr}; +use thiserror::Error; +use zeroize::Zeroize; + +/// Ed25519 Secret Key +#[derive(Clone)] +pub struct SecretKey([u8; Self::SIZE]); + +/// Ed25519 Extended Secret Key +/// +/// unlike [`SecretKey`], an extended key can be derived see +/// [`pallas_crypto::derivation`] +#[derive(Clone)] +pub struct SecretKeyExtended([u8; Self::SIZE]); + +/// Ed25519 Public Key. Can be used to verify a [`Signature`]. A [`PublicKey`] +/// is associated to a [`SecretKey`] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct PublicKey([u8; Self::SIZE]); + +/// Ed25519 Signature. Is created by a [`SecretKey`] and is verified +/// with a [`PublicKey`]. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct Signature([u8; Self::SIZE]); + +/// Error type used when retrieving a [`PublicKey`] via the [`TryFrom`] +/// trait. +#[derive(Debug, Error)] +pub enum TryFromPublicKeyError { + #[error("Invalid size, expecting {}", PublicKey::SIZE)] + InvalidSize, +} + +/// Error type used when retrieving a [`Signature`] via the [`TryFrom`] +/// trait. +#[derive(Debug, Error)] +pub enum TryFromSignatureError { + #[error("Invalid size, expecting {}", Signature::SIZE)] + InvalidSize, +} + +/// Error type used when retrieving a [`SecretKeyExtended`] via +/// [`SecretKeyExtended::from_bytes`] or [`TryFrom`]. +/// +#[derive(Debug, Error)] +pub enum TryFromSecretKeyExtendedError { + #[error("Invalid Ed25519 Extended Secret Key format")] + InvalidBitTweaks, +} + +macro_rules! impl_size_zero { + ($Type:ty, $Size:expr) => { + impl $Type { + /// This is the size of the type in bytes. + pub const SIZE: usize = $Size; + + /// create a zero object. This is not a _"valid"_ one. It is + /// used to initialize a ready to use data structure in this module. + #[inline] + fn zero() -> Self { + Self([0; Self::SIZE]) + } + } + }; +} + +impl_size_zero!(SecretKey, PRIVATE_KEY_LENGTH); +impl_size_zero!(SecretKeyExtended, EXTENDED_KEY_LENGTH); +impl_size_zero!(PublicKey, PUBLIC_KEY_LENGTH); +impl_size_zero!(Signature, SIGNATURE_LENGTH); + +impl SecretKey { + /// generate a new [`SecretKey`] with the given random number generator + pub fn new(mut rng: Rng) -> Self + where + Rng: RngCore + CryptoRng, + { + let mut s = Self::zero(); + rng.fill_bytes(&mut s.0); + s + } + + /// get the [`PublicKey`] associated to this key + /// + /// Unlike the [`SecretKey`], the [`PublicKey`] can be safely + /// publicly shared. The key can then be used to verify any + /// [`Signature`] generated with this [`SecretKey`] and the original + /// message. + pub fn public_key(&self) -> PublicKey { + let (mut sk, pk) = ed25519::keypair(&self.0); + + // the `sk` is a private component, scrubbing it reduce the + // risk of an adversary accessing the memory remains of this + // value + sk.zeroize(); + + PublicKey(pk) + } + + /// create a [`Signature`] for the given message with this [`SecretKey`]. + /// + /// The [`Signature`] can then be verified against the associated + /// [`PublicKey`] and the original message. + pub fn sign(&self, msg: T) -> Signature + where + T: AsRef<[u8]>, + { + let (mut sk, _) = ed25519::keypair(&self.0); + + let signature = ed25519::signature(msg.as_ref(), &sk); + + // we don't need this signature component, make sure to scrub the + // content before releasing the results + sk.zeroize(); + + Signature(signature) + } +} + +impl SecretKeyExtended { + /// generate a new [`SecretKeyExtended`] with the given random number + /// generator + pub fn new(mut rng: Rng) -> Self + where + Rng: RngCore + CryptoRng, + { + let mut s = Self::zero(); + rng.fill_bytes(&mut s.0); + + s.0[0] &= 0b1111_1000; + s.0[31] &= 0b0011_1111; + s.0[31] |= 0b0100_0000; + + debug_assert!( + s.check_structure(), + "checking we properly set the bit tweaks for the extended Ed25519" + ); + + s + } + + #[inline] + #[allow(clippy::verbose_bit_mask)] + fn check_structure(&self) -> bool { + (self.0[0] & 0b0000_0111) == 0 + && (self.0[31] & 0b0100_0000) == 0b0100_0000 + && (self.0[31] & 0b1000_0000) == 0 + } + + /// Retrieve a [`SecretKeyExtended`] from the given `bytes`` array. + /// + /// # error + /// + /// This function will check that the given bytes are valid for + /// an Ed25519 Extended Secret key. I.e. it will check that the + /// proper bits have been zeroed. + pub fn from_bytes(bytes: [u8; Self::SIZE]) -> Result { + let candidate = Self(bytes); + if candidate.check_structure() { + Ok(candidate) + } else { + Err(TryFromSecretKeyExtendedError::InvalidBitTweaks) + } + } + + /// get the [`PublicKey`] associated to this key + /// + /// Unlike the [`SecretKeyExtended`], the [`PublicKey`] can be safely + /// publicly shared. The key can then be used to verify any + /// [`Signature`] generated with this [`SecretKeyExtended`] and the original + /// message. + pub fn public_key(&self) -> PublicKey { + let pk = ed25519::extended_to_public(&self.0); + + PublicKey::from(pk) + } + + /// create a `Signature` for the given message with this `SecretKey`. + /// + /// The `Signature` can then be verified against the associated `PublicKey` + /// and the original message. + pub fn sign>(&self, msg: T) -> Signature { + let signature = ed25519::signature_extended(msg.as_ref(), &self.0); + + Signature::from(signature) + } +} + +impl PublicKey { + /// verify the cryptographic [`Signature`] against the `message` and the + /// [`PublicKey`] `self`. + #[inline] + pub fn verify(&self, message: T, signature: &Signature) -> bool + where + T: AsRef<[u8]>, + { + ed25519::verify(message.as_ref(), &self.0, &signature.0) + } +} + +/* Drop ******************************************************************** */ + +impl Drop for SecretKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl Drop for SecretKeyExtended { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/* Format ****************************************************************** */ + +impl fmt::Display for Signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.as_ref())) + } +} + +impl fmt::Display for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.as_ref())) + } +} + +impl fmt::Debug for Signature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Signature").field(&hex::encode(self.as_ref())).finish() + } +} + +impl fmt::Debug for PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicKey").field(&hex::encode(self.as_ref())).finish() + } +} + +macro_rules! impl_secret_fmt { + ($Type:ty) => { + /// conveniently provide a proper implementation to debug for the + /// SecretKey types when only *testing* the library + #[cfg(test)] + impl fmt::Debug for $Type { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple(&format!( + "SecretKey<{typename}>", + typename = type_name::() + )) + .field(&hex::encode(&self.0)) + .finish() + } + } + + /// conveniently provide an incomplete implementation of Debug for the + /// SecretKey. + #[cfg(not(test))] + impl fmt::Debug for $Type { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct(&format!( + "SecretKey<{typename}>", + typename = type_name::() + )) + .finish_non_exhaustive() + } + } + }; +} + +impl_secret_fmt!(SecretKey); +impl_secret_fmt!(SecretKeyExtended); + +/* AsRef ******************************************************************* */ + +impl AsRef<[u8]> for PublicKey { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl AsRef<[u8]> for Signature { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +/* Conversion ************************************************************** */ + +impl<'a> From<&'a Signature> for String { + fn from(s: &'a Signature) -> Self { + s.to_string() + } +} + +impl From for String { + fn from(s: Signature) -> Self { + s.to_string() + } +} + +impl From<[u8; Self::SIZE]> for PublicKey { + fn from(bytes: [u8; Self::SIZE]) -> Self { + Self(bytes) + } +} + +impl From for [u8; PublicKey::SIZE] { + fn from(pk: PublicKey) -> Self { + pk.0 + } +} + +impl From<[u8; Self::SIZE]> for Signature { + fn from(bytes: [u8; Self::SIZE]) -> Self { + Self(bytes) + } +} + +impl From<[u8; Self::SIZE]> for SecretKey { + fn from(bytes: [u8; Self::SIZE]) -> Self { + Self(bytes) + } +} + +impl TryFrom<[u8; Self::SIZE]> for SecretKeyExtended { + type Error = TryFromSecretKeyExtendedError; + fn try_from(bytes: [u8; Self::SIZE]) -> Result { + Self::from_bytes(bytes) + } +} + +impl<'a> TryFrom<&'a [u8]> for PublicKey { + type Error = TryFromPublicKeyError; + fn try_from(value: &'a [u8]) -> Result { + if value.len() != Self::SIZE { + Err(Self::Error::InvalidSize) + } else { + let mut s = Self::zero(); + s.0.copy_from_slice(value); + Ok(s) + } + } +} + +impl<'a> TryFrom<&'a [u8]> for Signature { + type Error = TryFromSignatureError; + fn try_from(value: &'a [u8]) -> Result { + if value.len() != Self::SIZE { + Err(Self::Error::InvalidSize) + } else { + let mut s = Self::zero(); + s.0.copy_from_slice(value); + Ok(s) + } + } +} + +impl FromStr for PublicKey { + type Err = hex::FromHexError; + fn from_str(s: &str) -> Result { + let mut r = Self::zero(); + hex::decode_to_slice(s, &mut r.0)?; + Ok(r) + } +} + +impl FromStr for Signature { + type Err = hex::FromHexError; + fn from_str(s: &str) -> Result { + let mut r = Self::zero(); + hex::decode_to_slice(s, &mut r.0)?; + Ok(r) + } +} + +impl<'a> TryFrom<&'a str> for Signature { + type Error = ::Err; + fn try_from(s: &'a str) -> Result { + s.parse() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use quickcheck::{Arbitrary, Gen, TestResult}; + use quickcheck_macros::quickcheck; + + impl Arbitrary for SecretKey { + fn arbitrary(g: &mut Gen) -> Self { + let mut s = Self::zero(); + s.0.iter_mut().for_each(|byte| { + *byte = u8::arbitrary(g); + }); + s + } + } + + impl Arbitrary for SecretKeyExtended { + fn arbitrary(g: &mut Gen) -> Self { + let mut s = Self::zero(); + s.0.iter_mut().for_each(|byte| { + *byte = u8::arbitrary(g); + }); + + s.0[0] &= 0b1111_1000; + s.0[31] &= 0b0011_1111; + s.0[31] |= 0b0100_0000; + + s + } + } + + impl Arbitrary for PublicKey { + fn arbitrary(g: &mut Gen) -> Self { + let mut s = Self::zero(); + s.0.iter_mut().for_each(|byte| { + *byte = u8::arbitrary(g); + }); + s + } + } + + impl Arbitrary for Signature { + fn arbitrary(g: &mut Gen) -> Self { + let mut s = Self::zero(); + s.0.iter_mut().for_each(|byte| { + *byte = u8::arbitrary(g); + }); + s + } + } + + #[quickcheck] + fn signing_verify_works(signing_key: SecretKey, message: Vec) -> bool { + let public_key = signing_key.public_key(); + let signature = signing_key.sign(&message); + + public_key.verify(message, &signature) + } + + #[quickcheck] + fn signing_verify_works_extended(signing_key: SecretKeyExtended, message: Vec) -> bool { + let public_key = signing_key.public_key(); + let signature = signing_key.sign(&message); + + public_key.verify(message, &signature) + } + + #[quickcheck] + fn verify_random_signature_does_not_work( + public_key: PublicKey, + signature: Signature, + message: Vec, + ) -> bool { + // NOTE: this test may fail but it is impossible to see this happening in normal + // condition. We are generating 32 random bytes of public key and + // 64 random bytes of signature with an randomly generated message + // of a random number of bytes in. If the message were empty, the + // probability to have a signature that matches the verify key + + // would still be 1 out of 2^96. + // + // if this test fails and it is not a bug, go buy a lottery ticket. + !public_key.verify(message, &signature) + } + + #[quickcheck] + fn public_key_try_from_correct_size(public_key: PublicKey) -> TestResult { + match PublicKey::try_from(public_key.as_ref()) { + Ok(_) => TestResult::passed(), + Err(TryFromPublicKeyError::InvalidSize) => { + TestResult::error("was expecting the test to pass") + } + } + } + + #[quickcheck] + fn public_key_try_from_incorrect_size(bytes: Vec) -> TestResult { + if bytes.len() == PublicKey::SIZE { + return TestResult::discard(); + } + match PublicKey::try_from(bytes.as_slice()) { + Ok(_) => TestResult::error( + "Expecting to fail with invalid size instead of having a valid value", + ), + Err(TryFromPublicKeyError::InvalidSize) => TestResult::passed(), + } + } + + #[quickcheck] + fn signature_try_from_correct_size(signature: Signature) -> TestResult { + match Signature::try_from(signature.as_ref()) { + Ok(_) => TestResult::passed(), + Err(TryFromSignatureError::InvalidSize) => { + TestResult::error("was expecting the test to pass") + } + } + } + + #[quickcheck] + fn signature_try_from_incorrect_size(bytes: Vec) -> TestResult { + if bytes.len() == Signature::SIZE { + return TestResult::discard(); + } + match Signature::try_from(bytes.as_slice()) { + Ok(_) => TestResult::error( + "Expecting to fail with invalid size instead of having a valid value", + ), + Err(TryFromSignatureError::InvalidSize) => TestResult::passed(), + } + } + + #[quickcheck] + fn public_key_from_str(public_key: PublicKey) -> TestResult { + let s = public_key.to_string(); + + match s.parse::() { + Ok(decoded) => { + if decoded == public_key { + TestResult::passed() + } else { + TestResult::error("the decoded key is not equal") + } + } + Err(error) => TestResult::error(error.to_string()), + } + } + + #[quickcheck] + fn signature_from_str(signature: Signature) -> TestResult { + let s = signature.to_string(); + + match s.parse::() { + Ok(decoded) => { + if decoded == signature { + TestResult::passed() + } else { + TestResult::error("the decoded signature is not equal") + } + } + Err(error) => TestResult::error(error.to_string()), + } + } +} diff --git a/modules/utxo_state/src/crypto/mod.rs b/modules/utxo_state/src/crypto/mod.rs new file mode 100644 index 00000000..eee2ff54 --- /dev/null +++ b/modules/utxo_state/src/crypto/mod.rs @@ -0,0 +1,4 @@ +pub mod ed25519; +pub mod utils; + +pub use utils::*; diff --git a/modules/utxo_state/src/crypto/utils.rs b/modules/utxo_state/src/crypto/utils.rs new file mode 100644 index 00000000..c8ab2255 --- /dev/null +++ b/modules/utxo_state/src/crypto/utils.rs @@ -0,0 +1,14 @@ +use crate::crypto::ed25519; +use acropolis_common::VKeyWitness; + +pub fn verify_ed25519_signature(witness: &VKeyWitness, data_to_verify: &[u8]) -> bool { + let mut pub_key_src: [u8; ed25519::PublicKey::SIZE] = [0; ed25519::PublicKey::SIZE]; + pub_key_src.copy_from_slice(&witness.vkey); + let pub_key = ed25519::PublicKey::from(pub_key_src); + + let mut sig_src: [u8; ed25519::Signature::SIZE] = [0; ed25519::Signature::SIZE]; + sig_src.copy_from_slice(&witness.signature); + let sig = ed25519::Signature::from(sig_src); + + pub_key.verify(data_to_verify, &sig) +} diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 7668b4ac..068b3c7e 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -35,6 +35,7 @@ use fjall_async_immutable_utxo_store::FjallAsyncImmutableUTXOStore; mod fake_immutable_utxo_store; use fake_immutable_utxo_store::FakeImmutableUTXOStore; +mod crypto; mod validations; const DEFAULT_SUBSCRIBE_TOPIC: &str = "cardano.utxo.deltas"; diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index 745964e4..4682a261 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -1,39 +1,373 @@ //! Shelley era UTxOW Rules //! Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L278 -use acropolis_common::{validation::UTxOWValidationError, UTXOValue, UTxOIdentifier}; +use std::collections::HashSet; + +use crate::crypto::verify_ed25519_signature; +use acropolis_common::{ + validation::UTxOWValidationError, AddrKeyhash, GenesisDelegates, KeyHash, NativeScript, + ScriptHash, ShelleyAddressPaymentPart, TxHash, TxOutRef, UTXOValue, VKeyWitness, +}; use anyhow::Result; use pallas::ledger::primitives::alonzo; -pub fn validate_withnesses( - tx: &alonzo::MintedTx, +fn get_vkey_witnesses(tx: &alonzo::MintedTx) -> Vec { + tx.transaction_witness_set + .vkeywitness + .as_ref() + .map(|witnesses| { + witnesses + .iter() + .map(|witness| VKeyWitness::new(witness.vkey.to_vec(), witness.signature.to_vec())) + .collect() + }) + .unwrap_or_default() +} + +pub fn eval_native_script( + native_script: &NativeScript, + vkey_hashes_provided: &HashSet, + low_bnd: Option, + upp_bnd: Option, +) -> bool { + match native_script { + NativeScript::ScriptAll(scripts) => scripts + .iter() + .all(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)), + NativeScript::ScriptAny(scripts) => scripts + .iter() + .any(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)), + NativeScript::ScriptPubkey(hash) => vkey_hashes_provided.contains(hash), + NativeScript::ScriptNOfK(val, scripts) => { + let count = scripts + .iter() + .map(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)) + .fold(0, |x, y| x + y as u32); + count >= *val + } + NativeScript::InvalidBefore(val) => { + match low_bnd { + Some(time) => *val >= time, + None => false, // as per mary-ledger.pdf, p.20 + } + } + NativeScript::InvalidHereafter(val) => { + match upp_bnd { + Some(time) => *val <= time, + None => false, // as per mary-ledger.pdf, p.20 + } + } + } +} + +/// This function extracts required VKey Hashes +/// from TxCert (pallas type) +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 +fn get_cert_authors(cert: &alonzo::Certificate) -> (HashSet, HashSet) { + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + let mut parse_cred = |cred: &alonzo::StakeCredential| match cred { + alonzo::StakeCredential::AddrKeyhash(vkey_hash) => { + vkey_hashes.insert(AddrKeyhash::from(**vkey_hash)); + } + alonzo::StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(ScriptHash::from(**script_hash)); + } + }; + + match cert { + // Deregistration requires witness from stake credential + alonzo::Certificate::StakeDeregistration(cred) => { + parse_cred(cred); + } + // Delegation requries withness from delegator + alonzo::Certificate::StakeDelegation(cred, _) => { + parse_cred(cred); + } + // Pool registration requires witness from pool cold key and owners + alonzo::Certificate::PoolRegistration { + operator, + pool_owners, + .. + } => { + vkey_hashes.insert(AddrKeyhash::from(**operator)); + vkey_hashes.extend(pool_owners.iter().map(|o| AddrKeyhash::from(**o))); + } + // Pool retirement requires withness from pool cold key + alonzo::Certificate::PoolRetirement(operator, _) => { + vkey_hashes.insert(AddrKeyhash::from(**operator)); + } + // Genesis delegation requires witness from genesis key + alonzo::Certificate::GenesisKeyDelegation(_, genesis_delegate_hash, _) => { + vkey_hashes.insert(AddrKeyhash::try_from(genesis_delegate_hash.as_ref()).unwrap()); + } + _ => {} + } + + (vkey_hashes, script_hashes) +} + +/// Get VKey Witnesses needed for transaction +/// Get Scripts needed for transaction +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L274 +/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L226 +/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L103 +/// +/// VKey Witnesses needed +/// 1. UTxO authors: keys that own the UTxO being spent +/// 2. Certificate authors: keys authorizing certificates +/// 3. Pool owners: owners that must sign pool registration +/// 4. Withdrawal authors: keys authorizing reward withdrawals +/// 5. Governance authors: keys authorizing governance actions (e.g. protocol update) +/// +/// Script Witnesses needed +/// 1. Input scripts: scripts locking UTxO being spent +/// 2. Withdrawal scripts: scripts controlling reward accounts +/// 3. Certificate scripts: scripts in certificate credentials. +pub fn get_vkey_script_needed( + transaction_body: &alonzo::TransactionBody, + tx_hash: TxHash, lookup_utxo: F, -) -> Result<(), Box> +) -> (HashSet, HashSet) where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(TxOutRef) -> Result>, { - for (input_index, input) in tx.transaction_body.inputs.iter().enumerate() { - let utxo_identifier = UTxOIdentifier::new(input. - match lookup_utxo(input) { - Ok(Some(utxo)) => { - if let Some(alonzo_comp_output) = MultiEraOutput::as_alonzo(multi_era_output) { - match get_payment_part(&alonzo_comp_output.address) - .ok_or(ShelleyMA(AddressDecoding))? - { - ShelleyPaymentPart::Key(payment_key_hash) => { - check_vk_wit(&payment_key_hash, tx_hash, vk_wits)? - } - ShelleyPaymentPart::Script(script_hash) => check_native_script_witness( - &script_hash, - &tx_wits - .native_script - .as_ref() - .map(|x| x.iter().map(|y| y.deref().clone()).collect()), - )?, + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + // for each UTxO, extract the needed vkey and script hashes + for input in transaction_body.inputs.iter() { + let tx_out_ref = TxOutRef::new(tx_hash, input.index as u16); + if let Ok(Some(utxo)) = lookup_utxo(tx_out_ref) { + // NOTE: + // Need to check inputs from byron bootstrap addresses + // with bootstrap witnesses + if let Some(payment_part) = utxo.address.get_payment_part() { + match payment_part { + ShelleyAddressPaymentPart::PaymentKeyHash(payment_key_hash) => { + vkey_hashes.insert(payment_key_hash); + } + ShelleyAddressPaymentPart::ScriptHash(script_hash) => { + script_hashes.insert(script_hash); } } } - None => return Err(ShelleyMA(InputNotInUTxO)), } } + + // for each certificate, get the required vkey and script hashes + for cert in transaction_body.certificates.as_ref().unwrap_or(&vec![]) { + let (v, s) = get_cert_authors(cert); + vkey_hashes.extend(v); + script_hashes.extend(s); + } + + // for each withdrawal, get the required vkey and script hashes + if let Some(withdrawals) = transaction_body.withdrawals.as_ref() { + for (key_hash, _) in withdrawals.iter() { + // NOTE: + // Withdrawal is guaranteed to be always AddrKeyhash??? + vkey_hashes.insert(AddrKeyhash::try_from(key_hash.as_ref()).unwrap()); + } + } + + // for each governance action, get the required vkey hashes + if let Some(update) = transaction_body.update.as_ref() { + for (genesis_key, _) in update.proposed_protocol_parameter_updates.iter() { + vkey_hashes.insert(AddrKeyhash::try_from(genesis_key.as_ref()).unwrap()); + } + } + + (vkey_hashes, script_hashes) +} + +/// Validate Native Scripts from Transaction witnesses +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L373 +pub fn validate_failed_native_scripts( + native_scripts: &Vec, + vkey_hashes_provided: &HashSet, + low_bnd: Option, + upp_bnd: Option, +) -> Result<(), Box> { + for native_script in native_scripts { + if !eval_native_script(native_script, vkey_hashes_provided, low_bnd, upp_bnd) { + return Err(Box::new( + UTxOWValidationError::ScriptWitnessNotValidatingUTXOW { + script_hash: native_script.compute_hash(), + }, + )); + } + } + + Ok(()) +} + +/// Validate all needed scripts are provided in witnesses +/// No missing, no extra +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L386 +pub fn validate_missing_extra_scripts( + script_hashes_needed: &HashSet, + native_scripts: &[NativeScript], +) -> Result<(), Box> { + // check for missing & extra scripts + let mut scripts_used = + native_scripts.iter().map(|script| (false, script.compute_hash())).collect::>(); + for script_hash in script_hashes_needed.iter() { + if let Some((used, _)) = scripts_used.iter_mut().find(|(u, h)| !(*u) && script_hash.eq(h)) { + *used = true; + } else { + return Err(Box::new( + UTxOWValidationError::MissingScriptWitnessesUTxOW { + script_hash: *script_hash, + }, + )); + } + } + + for (used, script_hash) in scripts_used.iter() { + if !*used { + return Err(Box::new( + UTxOWValidationError::ExtraneousScriptWitnessesUTXOW { + script_hash: *script_hash, + }, + )); + } + } + Ok(()) +} + +/// Validate that all vkey witnesses signatures +/// are verified +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L401 +pub fn validate_verified_wits( + vkey_witnesses: &Vec, + tx_hash: TxHash, +) -> Result<(), Box> { + for vkey_witness in vkey_witnesses.iter() { + if !verify_ed25519_signature(vkey_witness, tx_hash.as_ref()) { + return Err(Box::new(UTxOWValidationError::InvalidWitnessesUTxOW { + key_hash: vkey_witness.key_hash(), + witness: vkey_witness.clone(), + })); + } + } + Ok(()) +} + +/// Validate that all required witnesses are provided +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L424 +pub fn validate_needed_witnesses( + vkey_hashes_needed: &HashSet, + vkey_hashes_provided: &HashSet, +) -> Result<(), Box> { + for vkey_hash in vkey_hashes_needed.iter() { + if !vkey_hashes_provided.contains(vkey_hash) { + return Err(Box::new(UTxOWValidationError::MissingVKeyWitnessesUTxOW { + key_hash: *vkey_hash, + })); + } + } + Ok(()) +} + +/// Validate genesis keys signatures for MIR certificate +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L463 +pub fn validate_mir_insufficient_genesis_sigs( + transaction_body: &alonzo::TransactionBody, + vkey_hashes_provided: &HashSet, + genesis_delegs: &GenesisDelegates, + update_quorum: u32, +) -> Result<(), Box> { + let has_mir = transaction_body + .certificates + .as_ref() + .map(|certs| { + certs + .iter() + .any(|cert| matches!(cert, alonzo::Certificate::MoveInstantaneousRewardsCert(_))) + }) + .unwrap_or(false); + if !has_mir { + return Ok(()); + } + + let genesis_delegate_hashes = + genesis_delegs.as_ref().values().map(|delegate| delegate.delegate).collect::>(); + + // genSig := genDelegates ∩ witsKeyHashes + let genesis_sigs = + genesis_delegate_hashes.intersection(vkey_hashes_provided).copied().collect::>(); + + // Check: |genSig| ≥ Quorum + // If insufficient, report the signatures that were found (not the missing ones) + if genesis_sigs.len() < update_quorum as usize { + return Err(Box::new( + UTxOWValidationError::MIRInsufficientGenesisSigsUTXOW { + gensis_keys: genesis_sigs, + quorum: update_quorum, + }, + )); + } + + Ok(()) +} + +pub fn validate_withnesses( + tx: &alonzo::MintedTx, + tx_hash: TxHash, + genesis_delegs: &GenesisDelegates, + update_quorum: u32, + lookup_utxo: F, +) -> Result<(), Box> +where + F: Fn(TxOutRef) -> Result>, +{ + let transaction_body = &tx.transaction_body; + // Extract required vkey and script hashes + let (vkey_hashes_needed, script_hashes_needed) = + get_vkey_script_needed(transaction_body, tx_hash, lookup_utxo); + + // Extract vkey hashes from witnesses + let vkey_witnesses = get_vkey_witnesses(tx); + let vkey_hashes_provided = vkey_witnesses.iter().map(|w| w.key_hash()).collect::>(); + + let native_scripts: Vec = tx + .transaction_witness_set + .native_script + .as_ref() + .map(|scripts| { + scripts.iter().map(|script| acropolis_codec::map_native_script(script)).collect() + }) + .unwrap_or_default(); + + // validate native scripts + validate_failed_native_scripts( + &native_scripts, + &vkey_hashes_provided, + transaction_body.validity_interval_start, + transaction_body.ttl, + )?; + + // validate missing & extra scripts + validate_missing_extra_scripts(&script_hashes_needed, &native_scripts)?; + + // validate vkey witnesses signatures + validate_verified_wits(&vkey_witnesses, tx_hash)?; + + // validate required vkey witnesses are provided + validate_needed_witnesses(&vkey_hashes_needed, &vkey_hashes_provided)?; + + // NOTE: + // need to validate metadata + + // validate mir certificate genesis sig + validate_mir_insufficient_genesis_sigs( + transaction_body, + &vkey_hashes_provided, + genesis_delegs, + update_quorum, + )?; + + Ok(()) } From eaecc0d50832f510f333e2ebeff740fb5557bfed Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Tue, 9 Dec 2025 18:48:31 +0100 Subject: [PATCH 04/13] fix: clippy --- Cargo.lock | 2 - modules/utxo_state/Cargo.toml | 2 - modules/utxo_state/src/crypto/ed25519.rs | 257 +----------------- modules/utxo_state/src/crypto/utils.rs | 1 + .../src/validations/shelley/utxow.rs | 3 +- 5 files changed, 5 insertions(+), 260 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61559bdf..27f65959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,13 +521,11 @@ dependencies = [ "pallas 0.33.0", "quickcheck", "quickcheck_macros", - "rand_core 0.9.3", "serde_cbor", "sled", "thiserror 2.0.17", "tokio", "tracing", - "zeroize", ] [[package]] diff --git a/modules/utxo_state/Cargo.toml b/modules/utxo_state/Cargo.toml index 089c5986..273e0abc 100644 --- a/modules/utxo_state/Cargo.toml +++ b/modules/utxo_state/Cargo.toml @@ -25,9 +25,7 @@ tokio = { workspace = true } tracing = { workspace = true } pallas = { workspace = true } cryptoxide = "0.5.1" -rand_core = "0.9.3" thiserror = "2.0.17" -zeroize = "1.8.2" hex = { workspace = true } [dev-dependencies] diff --git a/modules/utxo_state/src/crypto/ed25519.rs b/modules/utxo_state/src/crypto/ed25519.rs index 3ec1a9d5..e4089cfa 100644 --- a/modules/utxo_state/src/crypto/ed25519.rs +++ b/modules/utxo_state/src/crypto/ed25519.rs @@ -1,31 +1,8 @@ //! Ed25519 and Ed25519Extended Asymmetric Keys //! -//! In this module we have both [`SecretKey`] which is a normal Ed25519 -//! asymmetric key and [`SecretKeyExtended`] asymmetric key. -//! They can both be used to generate [`Signature`] and submit valid -//! transactions. -//! -//! However, only the [`SecretKeyExtended`] can be used for HD derivation -//! (using [ed25519_bip32] or otherwise). - -use cryptoxide::ed25519::{ - self, EXTENDED_KEY_LENGTH, PRIVATE_KEY_LENGTH, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH, -}; -use rand_core::{CryptoRng, RngCore}; -use std::{any::type_name, convert::TryFrom, fmt, str::FromStr}; +use cryptoxide::ed25519::{self, PUBLIC_KEY_LENGTH, SIGNATURE_LENGTH}; +use std::{convert::TryFrom, fmt, str::FromStr}; use thiserror::Error; -use zeroize::Zeroize; - -/// Ed25519 Secret Key -#[derive(Clone)] -pub struct SecretKey([u8; Self::SIZE]); - -/// Ed25519 Extended Secret Key -/// -/// unlike [`SecretKey`], an extended key can be derived see -/// [`pallas_crypto::derivation`] -#[derive(Clone)] -pub struct SecretKeyExtended([u8; Self::SIZE]); /// Ed25519 Public Key. Can be used to verify a [`Signature`]. A [`PublicKey`] /// is associated to a [`SecretKey`] @@ -53,15 +30,6 @@ pub enum TryFromSignatureError { InvalidSize, } -/// Error type used when retrieving a [`SecretKeyExtended`] via -/// [`SecretKeyExtended::from_bytes`] or [`TryFrom`]. -/// -#[derive(Debug, Error)] -pub enum TryFromSecretKeyExtendedError { - #[error("Invalid Ed25519 Extended Secret Key format")] - InvalidBitTweaks, -} - macro_rules! impl_size_zero { ($Type:ty, $Size:expr) => { impl $Type { @@ -78,128 +46,9 @@ macro_rules! impl_size_zero { }; } -impl_size_zero!(SecretKey, PRIVATE_KEY_LENGTH); -impl_size_zero!(SecretKeyExtended, EXTENDED_KEY_LENGTH); impl_size_zero!(PublicKey, PUBLIC_KEY_LENGTH); impl_size_zero!(Signature, SIGNATURE_LENGTH); -impl SecretKey { - /// generate a new [`SecretKey`] with the given random number generator - pub fn new(mut rng: Rng) -> Self - where - Rng: RngCore + CryptoRng, - { - let mut s = Self::zero(); - rng.fill_bytes(&mut s.0); - s - } - - /// get the [`PublicKey`] associated to this key - /// - /// Unlike the [`SecretKey`], the [`PublicKey`] can be safely - /// publicly shared. The key can then be used to verify any - /// [`Signature`] generated with this [`SecretKey`] and the original - /// message. - pub fn public_key(&self) -> PublicKey { - let (mut sk, pk) = ed25519::keypair(&self.0); - - // the `sk` is a private component, scrubbing it reduce the - // risk of an adversary accessing the memory remains of this - // value - sk.zeroize(); - - PublicKey(pk) - } - - /// create a [`Signature`] for the given message with this [`SecretKey`]. - /// - /// The [`Signature`] can then be verified against the associated - /// [`PublicKey`] and the original message. - pub fn sign(&self, msg: T) -> Signature - where - T: AsRef<[u8]>, - { - let (mut sk, _) = ed25519::keypair(&self.0); - - let signature = ed25519::signature(msg.as_ref(), &sk); - - // we don't need this signature component, make sure to scrub the - // content before releasing the results - sk.zeroize(); - - Signature(signature) - } -} - -impl SecretKeyExtended { - /// generate a new [`SecretKeyExtended`] with the given random number - /// generator - pub fn new(mut rng: Rng) -> Self - where - Rng: RngCore + CryptoRng, - { - let mut s = Self::zero(); - rng.fill_bytes(&mut s.0); - - s.0[0] &= 0b1111_1000; - s.0[31] &= 0b0011_1111; - s.0[31] |= 0b0100_0000; - - debug_assert!( - s.check_structure(), - "checking we properly set the bit tweaks for the extended Ed25519" - ); - - s - } - - #[inline] - #[allow(clippy::verbose_bit_mask)] - fn check_structure(&self) -> bool { - (self.0[0] & 0b0000_0111) == 0 - && (self.0[31] & 0b0100_0000) == 0b0100_0000 - && (self.0[31] & 0b1000_0000) == 0 - } - - /// Retrieve a [`SecretKeyExtended`] from the given `bytes`` array. - /// - /// # error - /// - /// This function will check that the given bytes are valid for - /// an Ed25519 Extended Secret key. I.e. it will check that the - /// proper bits have been zeroed. - pub fn from_bytes(bytes: [u8; Self::SIZE]) -> Result { - let candidate = Self(bytes); - if candidate.check_structure() { - Ok(candidate) - } else { - Err(TryFromSecretKeyExtendedError::InvalidBitTweaks) - } - } - - /// get the [`PublicKey`] associated to this key - /// - /// Unlike the [`SecretKeyExtended`], the [`PublicKey`] can be safely - /// publicly shared. The key can then be used to verify any - /// [`Signature`] generated with this [`SecretKeyExtended`] and the original - /// message. - pub fn public_key(&self) -> PublicKey { - let pk = ed25519::extended_to_public(&self.0); - - PublicKey::from(pk) - } - - /// create a `Signature` for the given message with this `SecretKey`. - /// - /// The `Signature` can then be verified against the associated `PublicKey` - /// and the original message. - pub fn sign>(&self, msg: T) -> Signature { - let signature = ed25519::signature_extended(msg.as_ref(), &self.0); - - Signature::from(signature) - } -} - impl PublicKey { /// verify the cryptographic [`Signature`] against the `message` and the /// [`PublicKey`] `self`. @@ -212,20 +61,6 @@ impl PublicKey { } } -/* Drop ******************************************************************** */ - -impl Drop for SecretKey { - fn drop(&mut self) { - self.0.zeroize(); - } -} - -impl Drop for SecretKeyExtended { - fn drop(&mut self) { - self.0.zeroize(); - } -} - /* Format ****************************************************************** */ impl fmt::Display for Signature { @@ -252,40 +87,6 @@ impl fmt::Debug for PublicKey { } } -macro_rules! impl_secret_fmt { - ($Type:ty) => { - /// conveniently provide a proper implementation to debug for the - /// SecretKey types when only *testing* the library - #[cfg(test)] - impl fmt::Debug for $Type { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple(&format!( - "SecretKey<{typename}>", - typename = type_name::() - )) - .field(&hex::encode(&self.0)) - .finish() - } - } - - /// conveniently provide an incomplete implementation of Debug for the - /// SecretKey. - #[cfg(not(test))] - impl fmt::Debug for $Type { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct(&format!( - "SecretKey<{typename}>", - typename = type_name::() - )) - .finish_non_exhaustive() - } - } - }; -} - -impl_secret_fmt!(SecretKey); -impl_secret_fmt!(SecretKeyExtended); - /* AsRef ******************************************************************* */ impl AsRef<[u8]> for PublicKey { @@ -332,19 +133,6 @@ impl From<[u8; Self::SIZE]> for Signature { } } -impl From<[u8; Self::SIZE]> for SecretKey { - fn from(bytes: [u8; Self::SIZE]) -> Self { - Self(bytes) - } -} - -impl TryFrom<[u8; Self::SIZE]> for SecretKeyExtended { - type Error = TryFromSecretKeyExtendedError; - fn try_from(bytes: [u8; Self::SIZE]) -> Result { - Self::from_bytes(bytes) - } -} - impl<'a> TryFrom<&'a [u8]> for PublicKey { type Error = TryFromPublicKeyError; fn try_from(value: &'a [u8]) -> Result { @@ -402,31 +190,6 @@ mod tests { use quickcheck::{Arbitrary, Gen, TestResult}; use quickcheck_macros::quickcheck; - impl Arbitrary for SecretKey { - fn arbitrary(g: &mut Gen) -> Self { - let mut s = Self::zero(); - s.0.iter_mut().for_each(|byte| { - *byte = u8::arbitrary(g); - }); - s - } - } - - impl Arbitrary for SecretKeyExtended { - fn arbitrary(g: &mut Gen) -> Self { - let mut s = Self::zero(); - s.0.iter_mut().for_each(|byte| { - *byte = u8::arbitrary(g); - }); - - s.0[0] &= 0b1111_1000; - s.0[31] &= 0b0011_1111; - s.0[31] |= 0b0100_0000; - - s - } - } - impl Arbitrary for PublicKey { fn arbitrary(g: &mut Gen) -> Self { let mut s = Self::zero(); @@ -447,22 +210,6 @@ mod tests { } } - #[quickcheck] - fn signing_verify_works(signing_key: SecretKey, message: Vec) -> bool { - let public_key = signing_key.public_key(); - let signature = signing_key.sign(&message); - - public_key.verify(message, &signature) - } - - #[quickcheck] - fn signing_verify_works_extended(signing_key: SecretKeyExtended, message: Vec) -> bool { - let public_key = signing_key.public_key(); - let signature = signing_key.sign(&message); - - public_key.verify(message, &signature) - } - #[quickcheck] fn verify_random_signature_does_not_work( public_key: PublicKey, diff --git a/modules/utxo_state/src/crypto/utils.rs b/modules/utxo_state/src/crypto/utils.rs index c8ab2255..b0839831 100644 --- a/modules/utxo_state/src/crypto/utils.rs +++ b/modules/utxo_state/src/crypto/utils.rs @@ -1,6 +1,7 @@ use crate::crypto::ed25519; use acropolis_common::VKeyWitness; +#[allow(dead_code)] pub fn verify_ed25519_signature(witness: &VKeyWitness, data_to_verify: &[u8]) -> bool { let mut pub_key_src: [u8; ed25519::PublicKey::SIZE] = [0; ed25519::PublicKey::SIZE]; pub_key_src.copy_from_slice(&witness.vkey); diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index 4682a261..4f0e9aea 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -1,5 +1,6 @@ //! Shelley era UTxOW Rules //! Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L278 +#![allow(dead_code)] use std::collections::HashSet; @@ -241,7 +242,7 @@ pub fn validate_missing_extra_scripts( /// are verified /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L401 pub fn validate_verified_wits( - vkey_witnesses: &Vec, + vkey_witnesses: &[VKeyWitness], tx_hash: TxHash, ) -> Result<(), Box> { for vkey_witness in vkey_witnesses.iter() { From e027bf4565f9f24386410f3a6912a8ce28594ea6 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Wed, 10 Dec 2025 16:39:15 +0100 Subject: [PATCH 05/13] refactor: add bad inputs utxo check function --- .../utxo_state/src/validations/shelley/mod.rs | 1 + .../src/validations/shelley/utxo.rs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 modules/utxo_state/src/validations/shelley/utxo.rs diff --git a/modules/utxo_state/src/validations/shelley/mod.rs b/modules/utxo_state/src/validations/shelley/mod.rs index 4359b4e7..4967dcac 100644 --- a/modules/utxo_state/src/validations/shelley/mod.rs +++ b/modules/utxo_state/src/validations/shelley/mod.rs @@ -1 +1,2 @@ +pub mod utxo; pub mod utxow; diff --git a/modules/utxo_state/src/validations/shelley/utxo.rs b/modules/utxo_state/src/validations/shelley/utxo.rs new file mode 100644 index 00000000..e784613a --- /dev/null +++ b/modules/utxo_state/src/validations/shelley/utxo.rs @@ -0,0 +1,29 @@ +use acropolis_common::{validation::UTxOValidationError, UTXOValue, UTxOIdentifier}; +use anyhow::Result; +use pallas::ledger::primitives::alonzo; + +pub type UTxOValidationResult = Result<(), Box>; + +/// Validate every transaction's input exists in the current UTxO set. +/// This prevents double spending. +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxo.hs#L468 +pub fn validate_bad_inputs_utxo( + transaction_body: &alonzo::TransactionBody, + lookup_utxo: F, +) -> UTxOValidationResult +where + F: Fn(UTxOIdentifier) -> Result>, +{ + for (index, input) in transaction_body.inputs.iter().enumerate() { + let tx_ref = UTxOIdentifier::new((*input.transaction_id).into(), input.index as u16); + if let Ok(Some(_)) = lookup_utxo(tx_ref) { + continue; + } else { + return Err(Box::new(UTxOValidationError::BadInputsUTxO { + bad_input: tx_ref, + bad_input_index: index, + })); + } + } + Ok(()) +} From 2185c84d0e5ff1eab93587c59549f74d3b47db4d Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Thu, 11 Dec 2025 13:09:19 +0100 Subject: [PATCH 06/13] refactor: add validate function --- common/src/validation.rs | 4 +++ modules/utxo_state/src/validations/mod.rs | 36 +++++++++++++++++++ .../src/validations/shelley/utxo.rs | 11 ++++++ .../src/validations/shelley/utxow.rs | 2 +- 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/common/src/validation.rs b/common/src/validation.rs index 34d659a1..b6db6116 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -28,6 +28,10 @@ pub enum TransactionValidationError { #[error("{0}")] UTxOValidationError(#[from] UTxOValidationError), + /// **Cause:** UTxOW rules failure + #[error("{0}")] + UTxOWValidationError(#[from] UTxOWValidationError), + /// **Cause:** Other errors (e.g. Invalid shelley params) #[error("{0}")] Other(String), diff --git a/modules/utxo_state/src/validations/mod.rs b/modules/utxo_state/src/validations/mod.rs index 0acc2831..95829dec 100644 --- a/modules/utxo_state/src/validations/mod.rs +++ b/modules/utxo_state/src/validations/mod.rs @@ -1 +1,37 @@ +use acropolis_common::{ + validation::TransactionValidationError, Era, GenesisDelegates, TxHash, UTXOValue, + UTxOIdentifier, +}; +use anyhow::Result; +use pallas::ledger::traverse::{Era as PallasEra, MultiEraTx}; mod shelley; + +pub fn validate_shelley_tx( + raw_tx: &[u8], + genesis_delegs: &GenesisDelegates, + update_quorum: u32, + lookup_utxo: F, +) -> Result<(), TransactionValidationError> +where + F: Fn(UTxOIdentifier) -> Result>, +{ + let tx = MultiEraTx::decode_for_era(PallasEra::Shelley, raw_tx) + .map_err(|e| TransactionValidationError::CborDecodeError(e.to_string()))?; + let tx_hash = TxHash::from(*tx.hash()); + + let mtx = match tx { + MultiEraTx::AlonzoCompatible(mtx, PallasEra::Shelley) => mtx, + _ => { + return Err(TransactionValidationError::MalformedTransaction { + era: Era::Shelley, + reason: "Not a Shelley transaction".to_string(), + }); + } + }; + + shelley::utxo::validate(&mtx, &lookup_utxo).map_err(|e| *e)?; + shelley::utxow::validate(&mtx, tx_hash, genesis_delegs, update_quorum, &lookup_utxo) + .map_err(|e| *e)?; + + Ok(()) +} diff --git a/modules/utxo_state/src/validations/shelley/utxo.rs b/modules/utxo_state/src/validations/shelley/utxo.rs index e784613a..4312f72f 100644 --- a/modules/utxo_state/src/validations/shelley/utxo.rs +++ b/modules/utxo_state/src/validations/shelley/utxo.rs @@ -27,3 +27,14 @@ where } Ok(()) } + +pub fn validate(tx: &alonzo::MintedTx, lookup_utxo: F) -> UTxOValidationResult +where + F: Fn(UTxOIdentifier) -> Result>, +{ + let transaction_body = &tx.transaction_body; + + validate_bad_inputs_utxo(transaction_body, lookup_utxo)?; + + Ok(()) +} diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index ae415dab..912cfade 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -314,7 +314,7 @@ pub fn validate_mir_insufficient_genesis_sigs( Ok(()) } -pub fn validate_withnesses( +pub fn validate( tx: &alonzo::MintedTx, tx_hash: TxHash, genesis_delegs: &GenesisDelegates, From 8d7dba9d5230be3284da7c3aee5df36782c9a3c3 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Thu, 11 Dec 2025 16:56:24 +0100 Subject: [PATCH 07/13] chore: add pp in utxo state --- modules/utxo_state/src/utxo_state.rs | 163 ++++++++++++++++++++------- 1 file changed, 122 insertions(+), 41 deletions(-) diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 068b3c7e..7f68f72e 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -2,10 +2,13 @@ //! Accepts UTXO events and derives the current ledger state in memory use acropolis_common::{ + caryatid::SubscriptionExt, messages::{CardanoMessage, Message, StateQuery, StateQueryResponse, StateTransitionMessage}, + protocol_params::ProtocolParams, queries::utxos::{UTxOStateQuery, UTxOStateQueryResponse, DEFAULT_UTXOS_QUERY_TOPIC}, + state_history::{StateHistory, StateHistoryStore}, }; -use caryatid_sdk::{module, Context}; +use caryatid_sdk::{module, Context, Subscription}; use acropolis_common::queries::errors::QueryError; use anyhow::{anyhow, Result}; @@ -38,7 +41,16 @@ use fake_immutable_utxo_store::FakeImmutableUTXOStore; mod crypto; mod validations; -const DEFAULT_SUBSCRIBE_TOPIC: &str = "cardano.utxo.deltas"; +const DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = + ("utxo-deltas-subscribe-topic", "cardano.utxo.deltas"); +const DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC: (&str, &str) = ( + "bootstrapped-subscribe-topic", + "cardano.sequence.bootstrapped", +); +const DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = ( + "protocol-parameters-subscribe-topic", + "cardano.protocol.parameters", +); const DEFAULT_STORE: &str = "memory"; /// UTXO state module @@ -50,12 +62,96 @@ const DEFAULT_STORE: &str = "memory"; pub struct UTXOState; impl UTXOState { + /// Main run function + async fn run( + state: Arc>, + pp_history: Arc>>, + mut utxo_deltas_subscription: Box>, + mut bootstrapped_subscription: Box>, + mut protocol_parameters_subscription: Box>, + ) -> Result<()> { + let (_, bootstrapped_message) = bootstrapped_subscription.read().await?; + let genesis = match bootstrapped_message.as_ref() { + Message::Cardano((_, CardanoMessage::GenesisComplete(complete))) => { + complete.values.clone() + } + _ => panic!("Unexpected message in genesis completion topic: {bootstrapped_message:?}"), + }; + + // Consume initial protocol parameters + let _ = protocol_parameters_subscription.read().await?; + + loop { + let mut protocol_params = + pp_history.lock().await.get_or_init_with(ProtocolParams::default); + + let Ok((_, message)) = utxo_deltas_subscription.read().await else { + return Err(anyhow!("Failed to read UTxO deltas subscription error")); + }; + let new_epoch = match message.as_ref() { + Message::Cardano((block_info, CardanoMessage::UTXODeltas(_))) => { + block_info.new_epoch && block_info.epoch > 0 + } + _ => false, + }; + + if new_epoch { + let (_, protocol_parameters_msg) = + protocol_parameters_subscription.read_ignoring_rollbacks().await?; + if let Message::Cardano((_, CardanoMessage::ProtocolParams(params))) = + protocol_parameters_msg.as_ref() + { + protocol_params = params.params.clone(); + } + } + + match message.as_ref() { + Message::Cardano((block, CardanoMessage::UTXODeltas(deltas_msg))) => { + let span = info_span!("utxo_state.handle", block = block.number); + async { + let mut state = state.lock().await; + state + .handle(block, deltas_msg) + .await + .inspect_err(|e| error!("Messaging handling error: {e}")) + .ok(); + } + .instrument(span) + .await; + } + + Message::Cardano(( + _, + CardanoMessage::StateTransition(StateTransitionMessage::Rollback(_)), + )) => { + let mut state = state.lock().await; + state + .handle_rollback(message) + .await + .inspect_err(|e| error!("Rollback handling error: {e}")) + .ok(); + } + + _ => error!("Unexpected message type: {message:?}"), + } + } + } + /// Main init function pub async fn init(&self, context: Arc>, config: Arc) -> Result<()> { // Get configuration - let subscribe_topic = - config.get_string("subscribe-topic").unwrap_or(DEFAULT_SUBSCRIBE_TOPIC.to_string()); - info!("Creating subscriber on '{subscribe_topic}'"); + let utxo_deltas_subscribe_topic = config + .get_string(DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating subscriber on '{utxo_deltas_subscribe_topic}'"); + let bootstrapped_subscribe_topic = config + .get_string(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating bootstrapped subscriber on '{bootstrapped_subscribe_topic}'"); + let protocol_parameters_subscribe_topic = config + .get_string(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating protocol parameters subscriber on '{protocol_parameters_subscribe_topic}'"); let utxos_query_topic = config .get_string(DEFAULT_UTXOS_QUERY_TOPIC.0) @@ -81,44 +177,29 @@ impl UTXOState { let state = Arc::new(Mutex::new(state)); - // Subscribe for UTXO messages - let state1 = state.clone(); - let mut subscription = context.subscribe(&subscribe_topic).await?; - context.run(async move { - loop { - let Ok((_, message)) = subscription.read().await else { - return; - }; - match message.as_ref() { - Message::Cardano((block, CardanoMessage::UTXODeltas(deltas_msg))) => { - let span = info_span!("utxo_state.handle", block = block.number); - async { - let mut state = state1.lock().await; - state - .handle(block, deltas_msg) - .await - .inspect_err(|e| error!("Messaging handling error: {e}")) - .ok(); - } - .instrument(span) - .await; - } + // Subscribers + let utxo_deltas_subscription = context.subscribe(&utxo_deltas_subscribe_topic).await?; + let bootstrapped_subscription = context.subscribe(&bootstrapped_subscribe_topic).await?; + let protocol_parameters_subscription = + context.subscribe(&protocol_parameters_subscribe_topic).await?; - Message::Cardano(( - _, - CardanoMessage::StateTransition(StateTransitionMessage::Rollback(_)), - )) => { - let mut state = state1.lock().await; - state - .handle_rollback(message) - .await - .inspect_err(|e| error!("Rollback handling error: {e}")) - .ok(); - } + // Prepare validation state history + let validation_state_history = Arc::new(Mutex::new(StateHistory::::new( + "utxo-state-validation", + StateHistoryStore::default_block_store(), + ))); - _ => error!("Unexpected message type: {message:?}"), - } - } + let state_run = state.clone(); + context.run(async move { + Self::run( + state_run, + validation_state_history, + utxo_deltas_subscription, + bootstrapped_subscription, + protocol_parameters_subscription, + ) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); }); // Query handler From ffe26749b9261e81489308efc0de6010a0bdc2c6 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 10:27:48 +0100 Subject: [PATCH 08/13] feat: update utxow rule validate function to use acropolis common transaction types, make witnesses codec, introduce UnpackedTransactionsMessage which will be used by utxo_state to validate transactions --- codec/src/lib.rs | 2 + codec/src/utxo.rs | 23 +-- codec/src/witness.rs | 35 ++++ common/src/messages.rs | 8 + common/src/types.rs | 30 ++++ modules/utxo_state/src/utxo_state.rs | 6 +- modules/utxo_state/src/validations/mod.rs | 49 +++--- .../src/validations/shelley/utxo.rs | 21 +-- .../src/validations/shelley/utxow.rs | 150 +++++++++--------- processes/omnibus/omnibus.toml | 2 +- 10 files changed, 192 insertions(+), 134 deletions(-) create mode 100644 codec/src/witness.rs diff --git a/codec/src/lib.rs b/codec/src/lib.rs index e4d76629..e1be4fdd 100644 --- a/codec/src/lib.rs +++ b/codec/src/lib.rs @@ -5,6 +5,7 @@ mod parameter; mod tx; mod utils; mod utxo; +mod witness; pub use address::*; pub use block::*; @@ -13,3 +14,4 @@ pub use parameter::*; pub use tx::*; pub use utils::*; pub use utxo::*; +pub use witness::*; diff --git a/codec/src/utxo.rs b/codec/src/utxo.rs index 8883e8ac..6121a6b0 100644 --- a/codec/src/utxo.rs +++ b/codec/src/utxo.rs @@ -1,6 +1,6 @@ -use crate::address::map_address; +use crate::{address::map_address, witness::map_native_script}; use acropolis_common::{validation::TransactionValidationError, *}; -use pallas_primitives::{alonzo, conway}; +use pallas_primitives::conway; use pallas_traverse::{MultiEraInput, MultiEraPolicyAssets, MultiEraTx, MultiEraValue}; pub fn map_value(pallas_value: &MultiEraValue) -> Value { @@ -84,25 +84,6 @@ pub fn map_datum(datum: &Option) -> Option { } } -pub fn map_native_script(script: &alonzo::NativeScript) -> NativeScript { - match script { - alonzo::NativeScript::ScriptPubkey(addr_key_hash) => { - NativeScript::ScriptPubkey(AddrKeyhash::from(**addr_key_hash)) - } - alonzo::NativeScript::ScriptAll(scripts) => { - NativeScript::ScriptAll(scripts.iter().map(map_native_script).collect()) - } - alonzo::NativeScript::ScriptAny(scripts) => { - NativeScript::ScriptAny(scripts.iter().map(map_native_script).collect()) - } - alonzo::NativeScript::ScriptNOfK(n, scripts) => { - NativeScript::ScriptNOfK(*n, scripts.iter().map(map_native_script).collect()) - } - alonzo::NativeScript::InvalidBefore(slot_no) => NativeScript::InvalidBefore(*slot_no), - alonzo::NativeScript::InvalidHereafter(slot_no) => NativeScript::InvalidHereafter(*slot_no), - } -} - pub fn map_reference_script(script: &Option) -> Option { match script { Some(conway::PseudoScript::NativeScript(script)) => { diff --git a/codec/src/witness.rs b/codec/src/witness.rs new file mode 100644 index 00000000..59fe7381 --- /dev/null +++ b/codec/src/witness.rs @@ -0,0 +1,35 @@ +use acropolis_common::{AddrKeyhash, NativeScript, VKeyWitness}; +use pallas_primitives::{KeepRaw, alonzo}; + +pub fn map_vkey_witness(vkey_witness: &alonzo::VKeyWitness) -> VKeyWitness { + VKeyWitness::new(vkey_witness.vkey.to_vec(), vkey_witness.signature.to_vec()) +} + +pub fn map_vkey_witnesses(vkey_witnesses: &[alonzo::VKeyWitness]) -> Vec { + vkey_witnesses.iter().map(map_vkey_witness).collect() +} + +pub fn map_native_script(script: &alonzo::NativeScript) -> NativeScript { + match script { + alonzo::NativeScript::ScriptPubkey(addr_key_hash) => { + NativeScript::ScriptPubkey(AddrKeyhash::from(**addr_key_hash)) + } + alonzo::NativeScript::ScriptAll(scripts) => { + NativeScript::ScriptAll(scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::ScriptAny(scripts) => { + NativeScript::ScriptAny(scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::ScriptNOfK(n, scripts) => { + NativeScript::ScriptNOfK(*n, scripts.iter().map(map_native_script).collect()) + } + alonzo::NativeScript::InvalidBefore(slot_no) => NativeScript::InvalidBefore(*slot_no), + alonzo::NativeScript::InvalidHereafter(slot_no) => NativeScript::InvalidHereafter(*slot_no), + } +} + +pub fn map_native_scripts<'b>( + native_scripts: &[KeepRaw<'b, alonzo::NativeScript>], +) -> Vec { + native_scripts.iter().map(|script| map_native_script(script)).collect() +} diff --git a/common/src/messages.rs b/common/src/messages.rs index e6708cb7..9996984a 100644 --- a/common/src/messages.rs +++ b/common/src/messages.rs @@ -76,6 +76,13 @@ pub struct GenesisUTxOsMessage { pub utxos: Vec<(UTxOIdentifier, TxIdentifier)>, } +/// Message encapsulating multiple unpacked transactions, in order +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct UnpackedTransactionsMessage { + /// Ordered array of unpacked transactions + pub transactions: Vec, +} + /// Message encapsulating multiple UTXO deltas, in order #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UTXODeltasMessage { @@ -314,6 +321,7 @@ pub enum CardanoMessage { ReceivedTxs(RawTxsMessage), // Transaction available GenesisComplete(GenesisCompleteMessage), // Genesis UTXOs done + genesis params GenesisUTxOs(GenesisUTxOsMessage), // Genesis UTxOs with their UTxOIdentifiers + UnpackedTransactions(UnpackedTransactionsMessage), // Unpacked transactions received UTXODeltas(UTXODeltasMessage), // UTXO deltas received AssetDeltas(AssetDeltasMessage), // Asset mint and burn deltas TxCertificates(TxCertificatesMessage), // Transaction certificates received diff --git a/common/src/types.rs b/common/src/types.rs index 07e2e1a5..8d00740d 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -277,6 +277,36 @@ pub struct TxUTxODeltas { pub outputs: Vec, } +// Individual transaction details +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Transaction { + // Transaction in which delta occured + pub tx_identifier: TxIdentifier, + + // Created and spent UTxOs + pub inputs: Vec, + pub outputs: Vec, + + // Certificates + pub certificates: Vec, + + // Withdrawals + pub withdrawals: Vec, + + // Witnesses + pub vkey_witnesses: Vec, + pub native_scripts: Vec, + + // Low bound: validity interval start + pub low_bnd: Option, + + // Upp bound: ttl + pub upp_bnd: Option, + + // pp updates proposal + pub alonzo_babbage_update_proposal: Option, +} + /// Individual address balance change #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AddressDelta { diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 7f68f72e..cdf7a209 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -71,7 +71,7 @@ impl UTXOState { mut protocol_parameters_subscription: Box>, ) -> Result<()> { let (_, bootstrapped_message) = bootstrapped_subscription.read().await?; - let genesis = match bootstrapped_message.as_ref() { + let _genesis = match bootstrapped_message.as_ref() { Message::Cardano((_, CardanoMessage::GenesisComplete(complete))) => { complete.values.clone() } @@ -82,7 +82,7 @@ impl UTXOState { let _ = protocol_parameters_subscription.read().await?; loop { - let mut protocol_params = + let mut _protocol_params = pp_history.lock().await.get_or_init_with(ProtocolParams::default); let Ok((_, message)) = utxo_deltas_subscription.read().await else { @@ -101,7 +101,7 @@ impl UTXOState { if let Message::Cardano((_, CardanoMessage::ProtocolParams(params))) = protocol_parameters_msg.as_ref() { - protocol_params = params.params.clone(); + _protocol_params = params.params.clone(); } } diff --git a/modules/utxo_state/src/validations/mod.rs b/modules/utxo_state/src/validations/mod.rs index 3ded8f09..bf29fcef 100644 --- a/modules/utxo_state/src/validations/mod.rs +++ b/modules/utxo_state/src/validations/mod.rs @@ -1,37 +1,46 @@ use acropolis_common::{ validation::{Phase1ValidationError, TransactionValidationError}, - GenesisDelegates, TxHash, UTXOValue, UTxOIdentifier, + AlonzoBabbageUpdateProposal, GenesisDelegates, NativeScript, TxCertificateWithPos, TxHash, + UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, }; use anyhow::Result; -use pallas::ledger::traverse::{Era as PallasEra, MultiEraTx}; mod shelley; +#[allow(clippy::too_many_arguments)] pub fn validate_shelley_tx( - raw_tx: &[u8], + tx_hash: TxHash, + inputs: &[UTxOIdentifier], + certificates: &[TxCertificateWithPos], + withdrawals: &[Withdrawal], + alonzo_babbage_update_proposal: &Option, + vkey_witnesses: &[VKeyWitness], + native_scripts: &[NativeScript], + low_bnd: Option, + upp_bnd: Option, genesis_delegs: &GenesisDelegates, update_quorum: u32, lookup_utxo: F, ) -> Result<(), TransactionValidationError> where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(&UTxOIdentifier) -> Result>, { - let tx = MultiEraTx::decode_for_era(PallasEra::Shelley, raw_tx) - .map_err(|e| TransactionValidationError::CborDecodeError(e.to_string()))?; - let tx_hash = TxHash::from(*tx.hash()); - - let mtx = match tx { - MultiEraTx::AlonzoCompatible(mtx, PallasEra::Shelley) => mtx, - _ => { - return Err(TransactionValidationError::MalformedTransaction( - "Not a Shelley transaction".to_string(), - )); - } - }; - - shelley::utxo::validate(&mtx, &lookup_utxo) + shelley::utxo::validate(inputs, &lookup_utxo) .map_err(|e| Phase1ValidationError::UTxOValidationError(*e))?; - shelley::utxow::validate(&mtx, tx_hash, genesis_delegs, update_quorum, &lookup_utxo) - .map_err(|e| Phase1ValidationError::UTxOWValidationError(*e))?; + shelley::utxow::validate( + tx_hash, + inputs, + certificates, + withdrawals, + alonzo_babbage_update_proposal, + vkey_witnesses, + native_scripts, + low_bnd, + upp_bnd, + genesis_delegs, + update_quorum, + &lookup_utxo, + ) + .map_err(|e| Phase1ValidationError::UTxOWValidationError(*e))?; Ok(()) } diff --git a/modules/utxo_state/src/validations/shelley/utxo.rs b/modules/utxo_state/src/validations/shelley/utxo.rs index 4312f72f..f64bfea2 100644 --- a/modules/utxo_state/src/validations/shelley/utxo.rs +++ b/modules/utxo_state/src/validations/shelley/utxo.rs @@ -1,6 +1,5 @@ use acropolis_common::{validation::UTxOValidationError, UTXOValue, UTxOIdentifier}; use anyhow::Result; -use pallas::ledger::primitives::alonzo; pub type UTxOValidationResult = Result<(), Box>; @@ -8,19 +7,18 @@ pub type UTxOValidationResult = Result<(), Box>; /// This prevents double spending. /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxo.hs#L468 pub fn validate_bad_inputs_utxo( - transaction_body: &alonzo::TransactionBody, + inputs: &[UTxOIdentifier], lookup_utxo: F, ) -> UTxOValidationResult where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(&UTxOIdentifier) -> Result>, { - for (index, input) in transaction_body.inputs.iter().enumerate() { - let tx_ref = UTxOIdentifier::new((*input.transaction_id).into(), input.index as u16); - if let Ok(Some(_)) = lookup_utxo(tx_ref) { + for (index, input) in inputs.iter().enumerate() { + if let Ok(Some(_)) = lookup_utxo(input) { continue; } else { return Err(Box::new(UTxOValidationError::BadInputsUTxO { - bad_input: tx_ref, + bad_input: *input, bad_input_index: index, })); } @@ -28,13 +26,10 @@ where Ok(()) } -pub fn validate(tx: &alonzo::MintedTx, lookup_utxo: F) -> UTxOValidationResult +pub fn validate(inputs: &[UTxOIdentifier], lookup_utxo: F) -> UTxOValidationResult where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(&UTxOIdentifier) -> Result>, { - let transaction_body = &tx.transaction_body; - - validate_bad_inputs_utxo(transaction_body, lookup_utxo)?; - + validate_bad_inputs_utxo(inputs, lookup_utxo)?; Ok(()) } diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index 912cfade..1d6a9653 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -6,8 +6,9 @@ use std::collections::HashSet; use crate::crypto::verify_ed25519_signature; use acropolis_common::{ - validation::UTxOWValidationError, AddrKeyhash, GenesisDelegates, KeyHash, NativeScript, - ScriptHash, ShelleyAddressPaymentPart, TxHash, UTXOValue, UTxOIdentifier, VKeyWitness, + validation::UTxOWValidationError, AlonzoBabbageUpdateProposal, GenesisDelegates, KeyHash, + NativeScript, ScriptHash, ShelleyAddressPaymentPart, StakeCredential, TxCertificate, + TxCertificateWithPos, TxHash, UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, }; use anyhow::Result; use pallas::ledger::primitives::alonzo; @@ -64,44 +65,43 @@ pub fn eval_native_script( /// This function extracts required VKey Hashes /// from TxCert (pallas type) /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 -fn get_cert_authors(cert: &alonzo::Certificate) -> (HashSet, HashSet) { +fn get_cert_authors( + cert_with_pos: &TxCertificateWithPos, +) -> (HashSet, HashSet) { let mut vkey_hashes = HashSet::new(); let mut script_hashes = HashSet::new(); - let mut parse_cred = |cred: &alonzo::StakeCredential| match cred { - alonzo::StakeCredential::AddrKeyhash(vkey_hash) => { - vkey_hashes.insert(AddrKeyhash::from(**vkey_hash)); + let mut parse_cred = |cred: &StakeCredential| match cred { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(*vkey_hash); } - alonzo::StakeCredential::ScriptHash(script_hash) => { - script_hashes.insert(ScriptHash::from(**script_hash)); + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(*script_hash); } }; - match cert { + match &cert_with_pos.cert { // Deregistration requires witness from stake credential - alonzo::Certificate::StakeDeregistration(cred) => { - parse_cred(cred); + TxCertificate::StakeDeregistration(addr) => { + parse_cred(&addr.credential); } // Delegation requries withness from delegator - alonzo::Certificate::StakeDelegation(cred, _) => { - parse_cred(cred); + TxCertificate::StakeDelegation(deleg) => { + parse_cred(&deleg.stake_address.credential); } // Pool registration requires witness from pool cold key and owners - alonzo::Certificate::PoolRegistration { - operator, - pool_owners, - .. - } => { - vkey_hashes.insert(AddrKeyhash::from(**operator)); - vkey_hashes.extend(pool_owners.iter().map(|o| AddrKeyhash::from(**o))); + TxCertificate::PoolRegistration(pool_reg) => { + vkey_hashes.insert(*pool_reg.operator); + vkey_hashes + .extend(pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>()); } // Pool retirement requires withness from pool cold key - alonzo::Certificate::PoolRetirement(operator, _) => { - vkey_hashes.insert(AddrKeyhash::from(**operator)); + TxCertificate::PoolRetirement(retirement) => { + vkey_hashes.insert(*retirement.operator); } // Genesis delegation requires witness from genesis key - alonzo::Certificate::GenesisKeyDelegation(_, genesis_delegate_hash, _) => { - vkey_hashes.insert(AddrKeyhash::try_from(genesis_delegate_hash.as_ref()).unwrap()); + TxCertificate::GenesisKeyDelegation(gen_deleg) => { + vkey_hashes.insert(*gen_deleg.genesis_delegate_hash); } _ => {} } @@ -127,20 +127,21 @@ fn get_cert_authors(cert: &alonzo::Certificate) -> (HashSet, HashSet( - transaction_body: &alonzo::TransactionBody, - tx_hash: TxHash, + inputs: &[UTxOIdentifier], + certificates: &[TxCertificateWithPos], + withdrawals: &[Withdrawal], + alonzo_babbage_update_proposal: &Option, lookup_utxo: F, ) -> (HashSet, HashSet) where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(&UTxOIdentifier) -> Result>, { let mut vkey_hashes = HashSet::new(); let mut script_hashes = HashSet::new(); // for each UTxO, extract the needed vkey and script hashes - for utxo in transaction_body.inputs.iter() { - let tx_out_ref = UTxOIdentifier::new(tx_hash, utxo.index as u16); - if let Ok(Some(utxo)) = lookup_utxo(tx_out_ref) { + for utxo in inputs.iter() { + if let Ok(Some(utxo)) = lookup_utxo(utxo) { // NOTE: // Need to check inputs from byron bootstrap addresses // with bootstrap witnesses @@ -158,25 +159,28 @@ where } // for each certificate, get the required vkey and script hashes - for cert in transaction_body.certificates.as_ref().unwrap_or(&vec![]) { + for cert in certificates.iter() { let (v, s) = get_cert_authors(cert); vkey_hashes.extend(v); script_hashes.extend(s); } // for each withdrawal, get the required vkey and script hashes - if let Some(withdrawals) = transaction_body.withdrawals.as_ref() { - for (key_hash, _) in withdrawals.iter() { - // NOTE: - // Withdrawal is guaranteed to be always AddrKeyhash??? - vkey_hashes.insert(AddrKeyhash::try_from(key_hash.as_ref()).unwrap()); + for withdrawal in withdrawals.iter() { + match withdrawal.address.credential { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(vkey_hash); + } + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(script_hash); + } } } // for each governance action, get the required vkey hashes - if let Some(update) = transaction_body.update.as_ref() { - for (genesis_key, _) in update.proposed_protocol_parameter_updates.iter() { - vkey_hashes.insert(AddrKeyhash::try_from(genesis_key.as_ref()).unwrap()); + if let Some(update) = alonzo_babbage_update_proposal { + for (genesis_key, _) in update.proposals.iter() { + vkey_hashes.insert(*genesis_key); } } @@ -186,7 +190,7 @@ where /// Validate Native Scripts from Transaction witnesses /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L373 pub fn validate_failed_native_scripts( - native_scripts: &Vec, + native_scripts: &[NativeScript], vkey_hashes_provided: &HashSet, low_bnd: Option, upp_bnd: Option, @@ -275,20 +279,17 @@ pub fn validate_needed_witnesses( /// Validate genesis keys signatures for MIR certificate /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L463 pub fn validate_mir_insufficient_genesis_sigs( - transaction_body: &alonzo::TransactionBody, + certificates: &[TxCertificateWithPos], vkey_hashes_provided: &HashSet, genesis_delegs: &GenesisDelegates, update_quorum: u32, ) -> Result<(), Box> { - let has_mir = transaction_body - .certificates - .as_ref() - .map(|certs| { - certs - .iter() - .any(|cert| matches!(cert, alonzo::Certificate::MoveInstantaneousRewardsCert(_))) - }) - .unwrap_or(false); + let has_mir = certificates.iter().any(|cert_with_pos| { + matches!( + cert_with_pos.cert, + TxCertificate::MoveInstantaneousReward(_) + ) + }); if !has_mir { return Ok(()); } @@ -314,47 +315,44 @@ pub fn validate_mir_insufficient_genesis_sigs( Ok(()) } +#[allow(clippy::too_many_arguments)] pub fn validate( - tx: &alonzo::MintedTx, tx_hash: TxHash, + inputs: &[UTxOIdentifier], + certificates: &[TxCertificateWithPos], + withdrawals: &[Withdrawal], + alonzo_babbage_update_proposal: &Option, + vkey_witnesses: &[VKeyWitness], + native_scripts: &[NativeScript], + low_bnd: Option, + upp_bnd: Option, genesis_delegs: &GenesisDelegates, update_quorum: u32, lookup_utxo: F, ) -> Result<(), Box> where - F: Fn(UTxOIdentifier) -> Result>, + F: Fn(&UTxOIdentifier) -> Result>, { - let transaction_body = &tx.transaction_body; // Extract required vkey and script hashes - let (vkey_hashes_needed, script_hashes_needed) = - get_vkey_script_needed(transaction_body, tx_hash, lookup_utxo); - - // Extract vkey hashes from witnesses - let vkey_witnesses = get_vkey_witnesses(tx); + let (vkey_hashes_needed, script_hashes_needed) = get_vkey_script_needed( + inputs, + certificates, + withdrawals, + alonzo_babbage_update_proposal, + lookup_utxo, + ); + + // Extract vkey hashes from vkey_witnesses let vkey_hashes_provided = vkey_witnesses.iter().map(|w| w.key_hash()).collect::>(); - let native_scripts: Vec = tx - .transaction_witness_set - .native_script - .as_ref() - .map(|scripts| { - scripts.iter().map(|script| acropolis_codec::map_native_script(script)).collect() - }) - .unwrap_or_default(); - // validate native scripts - validate_failed_native_scripts( - &native_scripts, - &vkey_hashes_provided, - transaction_body.validity_interval_start, - transaction_body.ttl, - )?; + validate_failed_native_scripts(native_scripts, &vkey_hashes_provided, low_bnd, upp_bnd)?; // validate missing & extra scripts - validate_missing_extra_scripts(&script_hashes_needed, &native_scripts)?; + validate_missing_extra_scripts(&script_hashes_needed, native_scripts)?; // validate vkey witnesses signatures - validate_verified_wits(&vkey_witnesses, tx_hash)?; + validate_verified_wits(vkey_witnesses, tx_hash)?; // validate required vkey witnesses are provided validate_needed_witnesses(&vkey_hashes_needed, &vkey_hashes_provided)?; @@ -364,7 +362,7 @@ where // validate mir certificate genesis sig validate_mir_insufficient_genesis_sigs( - transaction_body, + certificates, &vkey_hashes_provided, genesis_delegs, update_quorum, diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index f2ded529..b8271348 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -57,7 +57,7 @@ publish-withdrawals-topic = "cardano.withdrawals" publish-certificates-topic = "cardano.certificates" publish-governance-topic = "cardano.governance" publish-block-txs-topic = "cardano.block.txs" -publish-tx-validation-topic = "cardano.validation.txs" +publish-tx-validation-topic = "cardano.validation.tx" network-name = "mainnet" [module.utxo-state] From fe8da289b18add54624d4e536df4d2b5ace21b19 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 18:20:39 +0100 Subject: [PATCH 09/13] feat: update TxUTxODeltas message to include vkey and script hashes needed and provided for validation, move some of utxow validations to tx_unpacker --- Cargo.lock | 12 +- codec/Cargo.toml | 1 + codec/src/address.rs | 13 +- codec/src/tx.rs | 231 +++++++++---- codec/src/utxo.rs | 144 +++----- common/src/messages.rs | 8 - common/src/types.rs | 122 +++++-- common/src/validation.rs | 19 +- modules/assets_state/src/state.rs | 111 ++---- .../src/genesis_bootstrapper.rs | 6 +- modules/tx_unpacker/Cargo.toml | 6 + .../src/crypto/ed25519.rs | 0 modules/tx_unpacker/src/crypto/mod.rs | 4 + .../src/crypto/utils.rs | 0 modules/tx_unpacker/src/state.rs | 10 +- modules/tx_unpacker/src/tx_unpacker.rs | 237 ++++++++----- modules/tx_unpacker/src/validations/mod.rs | 35 +- .../src/validations/shelley/mod.rs | 1 + .../tx_unpacker/src/validations/shelley/tx.rs | 29 +- .../src/validations/shelley/utxo.rs | 7 +- .../src/validations/shelley/utxow.rs | 318 ++++++++++++++++++ modules/utxo_state/Cargo.toml | 9 +- modules/utxo_state/src/crypto/mod.rs | 4 - modules/utxo_state/src/state.rs | 14 + modules/utxo_state/src/test_utils.rs | 62 ++++ modules/utxo_state/src/utxo_state.rs | 3 +- modules/utxo_state/src/validations/mod.rs | 34 +- .../src/validations/shelley/utxow.rs | 263 ++------------- 28 files changed, 1012 insertions(+), 691 deletions(-) rename modules/{utxo_state => tx_unpacker}/src/crypto/ed25519.rs (100%) create mode 100644 modules/tx_unpacker/src/crypto/mod.rs rename modules/{utxo_state => tx_unpacker}/src/crypto/utils.rs (100%) create mode 100644 modules/tx_unpacker/src/validations/shelley/utxow.rs delete mode 100644 modules/utxo_state/src/crypto/mod.rs create mode 100644 modules/utxo_state/src/test_utils.rs diff --git a/Cargo.lock b/Cargo.lock index 47b77478..144aaae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16,6 +16,7 @@ version = "0.1.0" dependencies = [ "acropolis_common", "anyhow", + "hex", "pallas 0.33.0", "pallas-primitives 0.33.0", "pallas-traverse 0.33.0", @@ -495,12 +496,16 @@ dependencies = [ "anyhow", "caryatid_sdk", "config", + "cryptoxide 0.5.1", "futures", "hex", "pallas 0.33.0", + "quickcheck", + "quickcheck_macros", "serde", "serde_json", "test-case", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -509,22 +514,17 @@ dependencies = [ name = "acropolis_module_utxo_state" version = "0.1.0" dependencies = [ - "acropolis_codec", "acropolis_common", "anyhow", "async-trait", "caryatid_sdk", "config", - "cryptoxide 0.5.1", "dashmap", "fjall", "hex", - "pallas 0.33.0", - "quickcheck", - "quickcheck_macros", + "serde", "serde_cbor", "sled", - "thiserror 2.0.17", "tokio", "tracing", ] diff --git a/codec/Cargo.toml b/codec/Cargo.toml index e80c45d2..127cab5f 100644 --- a/codec/Cargo.toml +++ b/codec/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" acropolis_common = { path = "../common" } anyhow = { workspace = true } +hex.workspace = true pallas = { workspace = true } pallas-primitives = { workspace = true } pallas-traverse = { workspace = true } diff --git a/codec/src/address.rs b/codec/src/address.rs index bf871e0b..ec5c01fc 100644 --- a/codec/src/address.rs +++ b/codec/src/address.rs @@ -2,12 +2,21 @@ use acropolis_common::{ Address, ByronAddress, NetworkId, ShelleyAddress, ShelleyAddressDelegationPart, ShelleyAddressPaymentPart, ShelleyAddressPointer, StakeAddress, StakeCredential, }; -use anyhow::Result; +use anyhow::{Result, anyhow}; use pallas::ledger::{ addresses as pallas_addresses, primitives::StakeCredential as PallasStakeCredential, }; -use crate::{tx::map_network, utils::to_hash}; +use crate::utils::to_hash; + +/// Map Pallas Network to our NetworkId +pub fn map_network(network: pallas_addresses::Network) -> Result { + match network { + pallas_addresses::Network::Mainnet => Ok(NetworkId::Mainnet), + pallas_addresses::Network::Testnet => Ok(NetworkId::Testnet), + _ => Err(anyhow!("Unknown network in address")), + } +} /// Derive our Address from a Pallas address // This is essentially a 1:1 mapping but makes the Message definitions independent diff --git a/codec/src/tx.rs b/codec/src/tx.rs index 5762a6b3..d05b9ead 100644 --- a/codec/src/tx.rs +++ b/codec/src/tx.rs @@ -1,73 +1,82 @@ -use acropolis_common::{AssetName, Metadata, MetadataInt, NativeAssetDelta, NetworkId, PolicyId}; -use anyhow::{Result, anyhow}; -use pallas::ledger::addresses; +use crate::{ + address::map_address, + certs::map_certificate, + parameter::{map_alonzo_update, map_babbage_update}, + utxo::{map_datum, map_reference_script, map_value}, + witness::{map_native_scripts, map_vkey_witnesses}, +}; +use acropolis_common::{validation::Phase1ValidationError, *}; use pallas_primitives::Metadatum as PallasMetadatum; -use pallas_traverse::MultiEraPolicyAssets; - -/// Map Pallas Network to our NetworkId -pub fn map_network(network: addresses::Network) -> Result { - match network { - addresses::Network::Mainnet => Ok(NetworkId::Mainnet), - addresses::Network::Testnet => Ok(NetworkId::Testnet), - _ => Err(anyhow!("Unknown network in address")), +use pallas_traverse::{Era as PallasEra, MultiEraInput, MultiEraTx}; + +pub fn map_transaction_inputs(inputs: &Vec) -> Vec { + let mut parsed_inputs = Vec::new(); + for input in inputs { + // MultiEraInput + let oref = input.output_ref(); + let utxo = UTxOIdentifier::new(TxHash::from(**oref.hash()), oref.index() as u16); + + parsed_inputs.push(utxo); } + + parsed_inputs } -pub fn map_mint_burn( - policy_group: &MultiEraPolicyAssets<'_>, -) -> Option<(PolicyId, Vec)> { - match policy_group { - MultiEraPolicyAssets::AlonzoCompatibleMint(policy, kvps) => { - let policy_id: PolicyId = match policy.as_ref().try_into() { - Ok(id) => id, - Err(_) => { - tracing::error!( - "Invalid policy id length: expected 28 bytes, got {}", - policy.len() - ); - return None; - } - }; - - let deltas = kvps - .iter() - .filter_map(|(name, amt)| { - AssetName::new(name).map(|asset_name| NativeAssetDelta { - name: asset_name, - amount: *amt, - }) - }) - .collect::>(); - - Some((policy_id, deltas)) - } +/// Parse transaction inputs and outputs, and return the parsed inputs, outputs, total output lovelace, and errors +pub fn map_transaction_inputs_outputs( + tx: &MultiEraTx, +) -> (Vec, Vec, u128, Vec) { + let mut parsed_inputs = Vec::new(); + let mut parsed_outputs = Vec::new(); + let mut errors = Vec::new(); + + let Ok(tx_hash) = tx.hash().to_vec().try_into() else { + errors.push(format!( + "Tx has incorrect hash length ({:?})", + tx.hash().to_vec() + )); + return (parsed_inputs, parsed_outputs, 0, errors); + }; - MultiEraPolicyAssets::ConwayMint(policy, kvps) => { - let policy_id: PolicyId = match policy.as_ref().try_into() { - Ok(id) => id, - Err(_) => { - tracing::error!( - "Invalid policy id length: expected 28 bytes, got {}", - policy.len() - ); - return None; + let inputs = tx.consumes(); + let outputs = tx.produces(); + + for input in inputs { + let utxo = UTxOIdentifier::new( + TxHash::from(**input.output_ref().hash()), + input.output_ref().index() as u16, + ); + parsed_inputs.push(utxo); + } + + let mut total_output = 0; + for (index, output) in outputs { + let utxo = UTxOIdentifier::new(tx_hash, index as u16); + + match output.address() { + Ok(pallas_address) => match map_address(&pallas_address) { + Ok(address) => { + // Add TxOutput to utxo_deltas + parsed_outputs.push(TxOutput { + utxo_identifier: utxo, + address, + value: map_value(&output.value()), + datum: map_datum(&output.datum()), + reference_script: map_reference_script(&output.script_ref()), + }); + total_output += output.value().coin() as u128; } - }; - - let deltas = kvps - .iter() - .filter_map(|(name, amt)| { - AssetName::new(name).map(|asset_name| NativeAssetDelta { - name: asset_name, - amount: i64::from(*amt), - }) - }) - .collect::>(); - Some((policy_id, deltas)) + Err(e) => { + errors.push(format!("Output {index} has been ignored: {e}")); + } + }, + Err(e) => { + errors.push(format!("Output {index} has been ignored: {e}")); + } } - - _ => None, } + + (parsed_inputs, parsed_outputs, total_output, errors) } pub fn map_metadata(metadata: &PallasMetadatum) -> Metadata { @@ -81,3 +90,101 @@ pub fn map_metadata(metadata: &PallasMetadatum) -> Metadata { } } } + +/// Map a Pallas Transaction to extract +/// inputs, outputs, total_output, certs, withdrawals, proposal_update, vkey_witnesses, native_scripts and errors +#[allow(clippy::type_complexity)] +pub fn map_transaction( + tx: &MultiEraTx, + raw_tx: &[u8], + tx_identifier: TxIdentifier, + network_id: NetworkId, + era: Era, +) -> ( + Vec, + Vec, + u128, + Vec, + Vec, + Option, + Vec, + Vec, + Option, +) { + let (inputs, outputs, total_output, input_output_errors) = map_transaction_inputs_outputs(tx); + + let mut errors = Vec::new(); + let mut certs = Vec::new(); + let mut withdrawals = Vec::new(); + let mut alonzo_babbage_update_proposal = None; + + for (cert_index, cert) in tx.certs().iter().enumerate() { + match map_certificate(cert, tx_identifier, cert_index, network_id.clone()) { + Ok(c) => certs.push(c), + Err(e) => errors.push(format!("Certificate {cert_index} has been ignored: {e}")), + } + } + + for (key, value) in tx.withdrawals_sorted_set() { + match StakeAddress::from_binary(key) { + Ok(stake_address) => { + withdrawals.push(Withdrawal { + address: stake_address, + value, + tx_identifier, + }); + } + Err(e) => errors.push(format!( + "Withdrawal {} has been ignored: {e}", + hex::encode(key) + )), + } + } + + if era >= Era::Shelley && era < Era::Babbage { + if let Ok(alonzo) = MultiEraTx::decode_for_era(PallasEra::Alonzo, raw_tx) + && let Some(update) = alonzo.update() + && let Some(alonzo_update) = update.as_alonzo() + { + match map_alonzo_update(alonzo_update) { + Ok(p) => { + alonzo_babbage_update_proposal = Some(p); + } + Err(e) => errors.push(format!("Cannot decode alonzo update: {e}")), + } + } + } else if era >= Era::Babbage + && era < Era::Conway + && let Ok(babbage) = MultiEraTx::decode_for_era(PallasEra::Babbage, raw_tx) + && let Some(update) = babbage.update() + && let Some(babbage_update) = update.as_babbage() + { + match map_babbage_update(babbage_update) { + Ok(p) => { + alonzo_babbage_update_proposal = Some(p); + } + Err(e) => errors.push(format!("Cannot decode babbage update: {e}")), + } + } + + let vkey_witnesses = map_vkey_witnesses(tx.vkey_witnesses()); + let native_scripts = map_native_scripts(tx.native_scripts()); + + errors.extend(input_output_errors); + + ( + inputs, + outputs, + total_output, + certs, + withdrawals, + alonzo_babbage_update_proposal, + vkey_witnesses, + native_scripts, + if errors.is_empty() { + None + } else { + Some(Phase1ValidationError::MalformedTransaction { errors }) + }, + ) +} diff --git a/codec/src/utxo.rs b/codec/src/utxo.rs index 6121a6b0..ee34609e 100644 --- a/codec/src/utxo.rs +++ b/codec/src/utxo.rs @@ -1,7 +1,7 @@ -use crate::{address::map_address, witness::map_native_script}; -use acropolis_common::{validation::TransactionValidationError, *}; +use crate::witness::map_native_script; +use acropolis_common::*; use pallas_primitives::conway; -use pallas_traverse::{MultiEraInput, MultiEraPolicyAssets, MultiEraTx, MultiEraValue}; +use pallas_traverse::{MultiEraPolicyAssets, MultiEraValue}; pub fn map_value(pallas_value: &MultiEraValue) -> Value { let lovelace = pallas_value.coin(); @@ -63,19 +63,6 @@ pub fn map_value(pallas_value: &MultiEraValue) -> Value { Value::new(lovelace, assets) } -pub fn map_transaction_inputs(inputs: &Vec) -> Vec { - let mut parsed_inputs = Vec::new(); - for input in inputs { - // MultiEraInput - let oref = input.output_ref(); - let utxo = UTxOIdentifier::new(TxHash::from(**oref.hash()), oref.index() as u16); - - parsed_inputs.push(utxo); - } - - parsed_inputs -} - pub fn map_datum(datum: &Option) -> Option { match datum { Some(conway::MintedDatumOption::Hash(h)) => Some(Datum::Hash(h.to_vec())), @@ -102,82 +89,59 @@ pub fn map_reference_script(script: &Option) -> Option< } } -/// Parse transaction inputs and outputs, and return the parsed inputs, outputs, total output lovelace, and errors -pub fn map_transaction_inputs_outputs( - tx: &MultiEraTx, -) -> ( - Vec, - Vec, - u128, - Option, -) { - let mut parsed_inputs = Vec::new(); - let mut parsed_outputs = Vec::new(); - let mut errors = Vec::new(); - - let Ok(tx_hash) = tx.hash().to_vec().try_into() else { - errors.push(format!( - "Tx has incorrect hash length ({:?})", - tx.hash().to_vec() - )); - return ( - parsed_inputs, - parsed_outputs, - 0, - Some(TransactionValidationError::MalformedTransaction( - errors.join("; "), - )), - ); - }; - - let inputs = tx.consumes(); - let outputs = tx.produces(); - - for input in inputs { - let utxo = UTxOIdentifier::new( - TxHash::from(**input.output_ref().hash()), - input.output_ref().index() as u16, - ); - parsed_inputs.push(utxo); - } - - let mut total_output = 0; - for (index, output) in outputs { - let utxo = UTxOIdentifier::new(tx_hash, index as u16); - - match output.address() { - Ok(pallas_address) => match map_address(&pallas_address) { - Ok(address) => { - // Add TxOutput to utxo_deltas - parsed_outputs.push(TxOutput { - utxo_identifier: utxo, - address, - value: map_value(&output.value()), - datum: map_datum(&output.datum()), - reference_script: map_reference_script(&output.script_ref()), - }); - total_output += output.value().coin() as u128; +pub fn map_mint_burn( + policy_group: &MultiEraPolicyAssets<'_>, +) -> Option<(PolicyId, Vec)> { + match policy_group { + MultiEraPolicyAssets::AlonzoCompatibleMint(policy, kvps) => { + let policy_id: PolicyId = match policy.as_ref().try_into() { + Ok(id) => id, + Err(_) => { + tracing::error!( + "Invalid policy id length: expected 28 bytes, got {}", + policy.len() + ); + return None; } - Err(e) => { - errors.push(format!("Output {index} has been ignored: {e}")); + }; + + let deltas = kvps + .iter() + .filter_map(|(name, amt)| { + AssetName::new(name).map(|asset_name| NativeAssetDelta { + name: asset_name, + amount: *amt, + }) + }) + .collect::>(); + + Some((policy_id, deltas)) + } + + MultiEraPolicyAssets::ConwayMint(policy, kvps) => { + let policy_id: PolicyId = match policy.as_ref().try_into() { + Ok(id) => id, + Err(_) => { + tracing::error!( + "Invalid policy id length: expected 28 bytes, got {}", + policy.len() + ); + return None; } - }, - Err(e) => { - errors.push(format!("Output {index} has been ignored: {e}")); - } + }; + + let deltas = kvps + .iter() + .filter_map(|(name, amt)| { + AssetName::new(name).map(|asset_name| NativeAssetDelta { + name: asset_name, + amount: i64::from(*amt), + }) + }) + .collect::>(); + Some((policy_id, deltas)) } - } - ( - parsed_inputs, - parsed_outputs, - total_output, - if errors.is_empty() { - None - } else { - Some(TransactionValidationError::MalformedTransaction( - errors.join("; "), - )) - }, - ) + _ => None, + } } diff --git a/common/src/messages.rs b/common/src/messages.rs index 9996984a..e6708cb7 100644 --- a/common/src/messages.rs +++ b/common/src/messages.rs @@ -76,13 +76,6 @@ pub struct GenesisUTxOsMessage { pub utxos: Vec<(UTxOIdentifier, TxIdentifier)>, } -/// Message encapsulating multiple unpacked transactions, in order -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct UnpackedTransactionsMessage { - /// Ordered array of unpacked transactions - pub transactions: Vec, -} - /// Message encapsulating multiple UTXO deltas, in order #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UTXODeltasMessage { @@ -321,7 +314,6 @@ pub enum CardanoMessage { ReceivedTxs(RawTxsMessage), // Transaction available GenesisComplete(GenesisCompleteMessage), // Genesis UTXOs done + genesis params GenesisUTxOs(GenesisUTxOsMessage), // Genesis UTxOs with their UTxOIdentifiers - UnpackedTransactions(UnpackedTransactionsMessage), // Unpacked transactions received UTXODeltas(UTXODeltasMessage), // UTXO deltas received AssetDeltas(AssetDeltasMessage), // Asset mint and burn deltas TxCertificates(TxCertificatesMessage), // Transaction certificates received diff --git a/common/src/types.rs b/common/src/types.rs index 8d00740d..39db7e5b 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -275,36 +275,14 @@ pub struct TxUTxODeltas { // Created and spent UTxOs pub inputs: Vec, pub outputs: Vec, -} - -// Individual transaction details -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Transaction { - // Transaction in which delta occured - pub tx_identifier: TxIdentifier, - - // Created and spent UTxOs - pub inputs: Vec, - pub outputs: Vec, - - // Certificates - pub certificates: Vec, - - // Withdrawals - pub withdrawals: Vec, - // Witnesses - pub vkey_witnesses: Vec, - pub native_scripts: Vec, - - // Low bound: validity interval start - pub low_bnd: Option, - - // Upp bound: ttl - pub upp_bnd: Option, - - // pp updates proposal - pub alonzo_babbage_update_proposal: Option, + // State needed for validation + // This is missing UTxO Authors + pub vkey_hashes_needed: HashSet, + pub script_hashes_needed: HashSet, + // From witnesses + pub vkey_hashes_provided: Vec, + pub script_hashes_provided: Vec, } /// Individual address balance change @@ -594,6 +572,42 @@ impl NativeScript { data.extend_from_slice(raw_bytes.as_slice()); ScriptHash::from(keyhash_224(&data)) } + + pub fn eval( + &self, + vkey_hashes_provided: &HashSet, + low_bnd: Option, + upp_bnd: Option, + ) -> bool { + match self { + Self::ScriptAll(scripts) => { + scripts.iter().all(|script| script.eval(vkey_hashes_provided, low_bnd, upp_bnd)) + } + Self::ScriptAny(scripts) => { + scripts.iter().any(|script| script.eval(vkey_hashes_provided, low_bnd, upp_bnd)) + } + Self::ScriptPubkey(hash) => vkey_hashes_provided.contains(hash), + Self::ScriptNOfK(val, scripts) => { + let count = scripts + .iter() + .map(|script| script.eval(vkey_hashes_provided, low_bnd, upp_bnd)) + .fold(0, |x, y| x + y as u32); + count >= *val + } + Self::InvalidBefore(val) => { + match low_bnd { + Some(time) => *val >= time, + None => false, // as per mary-ledger.pdf, p.20 + } + } + Self::InvalidHereafter(val) => { + match upp_bnd { + Some(time) => *val <= time, + None => false, // as per mary-ledger.pdf, p.20 + } + } + } + } } /// Value (lovelace + multiasset) @@ -2515,6 +2529,56 @@ pub enum TxCertificate { DRepUpdate(DRepUpdate), } +impl TxCertificate { + /// This function extracts required VKey Hashes + /// from TxCertificate + /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 + /// + /// returns (vkey_hashes, script_hashes) + pub fn get_cert_authors(&self) -> (HashSet, HashSet) { + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + let mut parse_cred = |cred: &StakeCredential| match cred { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(*vkey_hash); + } + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(*script_hash); + } + }; + + match self { + // Deregistration requires witness from stake credential + Self::StakeDeregistration(addr) => { + parse_cred(&addr.credential); + } + // Delegation requries withness from delegator + Self::StakeDelegation(deleg) => { + parse_cred(&deleg.stake_address.credential); + } + // Pool registration requires witness from pool cold key and owners + Self::PoolRegistration(pool_reg) => { + vkey_hashes.insert(*pool_reg.operator); + vkey_hashes.extend( + pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>(), + ); + } + // Pool retirement requires withness from pool cold key + Self::PoolRetirement(retirement) => { + vkey_hashes.insert(*retirement.operator); + } + // Genesis delegation requires witness from genesis key + Self::GenesisKeyDelegation(gen_deleg) => { + vkey_hashes.insert(*gen_deleg.genesis_delegate_hash); + } + _ => {} + } + + (vkey_hashes, script_hashes) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TxCertificateWithPos { pub cert: TxCertificate, diff --git a/common/src/validation.rs b/common/src/validation.rs index 923bc509..b78f942b 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -52,12 +52,8 @@ pub enum ValidationError { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error, PartialEq, Eq)] pub enum TransactionValidationError { /// **Cause**: Raw Transaction CBOR is invalid - #[error("CBOR Decoding error: {0}")] - CborDecodeError(String), - - /// **Cause**: Transaction is not in correct form. - #[error("Malformed Transaction: {0}")] - MalformedTransaction(String), + #[error("CBOR Decoding error: era={era}, reason={reason}")] + CborDecodeError { era: Era, reason: String }, /// **Cause**: Phase 1 Validation Error #[error("Phase 1 Validation Failed: {0}")] @@ -70,6 +66,13 @@ pub enum TransactionValidationError { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Error, PartialEq, Eq)] pub enum Phase1ValidationError { + /// **Cause**: Transaction is not in correct form. + #[error( + "Malformed Transaction: {}", + errors.iter().map(|e| e.to_string()).collect::>().join("; ") + )] + MalformedTransaction { errors: Vec }, + /// **Cause:** The UTXO has expired (Shelley only) #[error("Expired UTXO: ttl={ttl}, current_slot={current_slot}")] ExpiredUTxO { ttl: Slot, current_slot: Slot }, @@ -92,10 +95,6 @@ pub enum Phase1ValidationError { /// **Cause:** UTxOW rules failure #[error("{0}")] UTxOWValidationError(#[from] UTxOWValidationError), - - /// **Cause:** Other errors (e.g. Invalid shelley params) - #[error("{0}")] - Other(String), } /// UTxO Rules Failure diff --git a/modules/assets_state/src/state.rs b/modules/assets_state/src/state.rs index b7fb2a7d..cfc65638 100644 --- a/modules/assets_state/src/state.rs +++ b/modules/assets_state/src/state.rs @@ -682,7 +682,7 @@ impl State { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, HashSet}; use crate::{ asset_registry::{AssetId, AssetRegistry}, @@ -862,6 +862,22 @@ mod tests { } } + fn make_tx_utxo_deltas( + tx_identifier: TxIdentifier, + inputs: Vec, + outputs: Vec, + ) -> TxUTxODeltas { + TxUTxODeltas { + tx_identifier, + inputs, + outputs, + vkey_hashes_needed: HashSet::new(), + script_hashes_needed: HashSet::new(), + vkey_hashes_provided: vec![], + script_hashes_provided: vec![], + } + } + #[test] fn mint_creates_new_asset_and_updates_all_fields() { let mut registry = AssetRegistry::new(); @@ -1169,11 +1185,7 @@ mod tests { let datum_blob = vec![1, 2, 3, 4]; let output = make_output(policy_id, reference_name, Some(datum_blob.clone())); - let tx_deltas = TxUTxODeltas { - tx_identifier: TxIdentifier::new(0, 0), - inputs: Vec::new(), - outputs: vec![output], - }; + let tx_deltas = make_tx_utxo_deltas(TxIdentifier::new(0, 0), vec![], vec![output]); let new_state = state.handle_cip68_metadata(&[tx_deltas], ®istry).unwrap(); let info = new_state.info.expect("info should be Some"); @@ -1200,11 +1212,7 @@ mod tests { let datum_blob = vec![1, 2, 3, 4]; let output = make_output(policy_id, normal_name, Some(datum_blob.clone())); - let tx_deltas = TxUTxODeltas { - tx_identifier: TxIdentifier::new(0, 0), - inputs: Vec::new(), - outputs: vec![output], - }; + let tx_deltas = make_tx_utxo_deltas(TxIdentifier::new(0, 0), vec![], vec![output]); let new_state = state.handle_cip68_metadata(&[tx_deltas], ®istry).unwrap(); @@ -1232,11 +1240,7 @@ mod tests { let datum_blob = vec![1, 2, 3, 4]; let output = make_output(policy_id, name, Some(datum_blob)); - let tx_deltas = TxUTxODeltas { - tx_identifier: TxIdentifier::new(0, 0), - inputs: Vec::new(), - outputs: vec![output], - }; + let tx_deltas = make_tx_utxo_deltas(TxIdentifier::new(0, 0), vec![], vec![output]); let new_state = state.handle_cip68_metadata(&[tx_deltas], ®istry).unwrap(); @@ -1266,11 +1270,7 @@ mod tests { let input = UTxOIdentifier::new(TxHash::default(), 0); let output = make_output(policy_id, name, None); - let tx_deltas = TxUTxODeltas { - tx_identifier: TxIdentifier::new(0, 0), - inputs: vec![input], - outputs: vec![output], - }; + let tx_deltas = make_tx_utxo_deltas(TxIdentifier::new(0, 0), vec![input], vec![output]); let new_state = state.handle_cip68_metadata(&[tx_deltas], ®istry).unwrap(); @@ -1308,11 +1308,7 @@ mod tests { let output = make_output(policy_id, name, Some(datum.clone())); - let tx = TxUTxODeltas { - tx_identifier: TxIdentifier::new(0, 0), - inputs: vec![], - outputs: vec![output], - }; + let tx = make_tx_utxo_deltas(TxIdentifier::new(0, 0), vec![], vec![output]); let new_state = state.handle_cip68_metadata(&[tx], ®istry).unwrap(); let record = new_state.info.as_ref().unwrap().get(&asset_id).unwrap(); @@ -1424,22 +1420,8 @@ mod tests { let tx_identifier = TxIdentifier::new(0, 0); - let tx1 = TxUTxODeltas { - tx_identifier, - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..output.clone() - }], - }; - let tx2 = TxUTxODeltas { - tx_identifier, - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 1), - ..output - }], - }; + let tx1 = make_tx_utxo_deltas(tx_identifier, vec![], vec![output.clone()]); + let tx2 = make_tx_utxo_deltas(tx_identifier, vec![], vec![output]); let new_state = state.handle_transactions(&[tx1, tx2], ®istry).unwrap(); let txs = new_state.transactions.expect("transactions should exist"); @@ -1466,22 +1448,8 @@ mod tests { let out1 = make_output(policy_id, asset_name, None); let out2 = make_output(policy_id, asset_name, None); - let tx1 = TxUTxODeltas { - tx_identifier: TxIdentifier::new(9, 0), - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..out1 - }], - }; - let tx2 = TxUTxODeltas { - tx_identifier: TxIdentifier::new(10, 0), - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..out2 - }], - }; + let tx1 = make_tx_utxo_deltas(TxIdentifier::new(9, 0), vec![], vec![out1]); + let tx2 = make_tx_utxo_deltas(TxIdentifier::new(10, 0), vec![], vec![out2]); let new_state = state.handle_transactions(&[tx1, tx2], ®istry).unwrap(); let txs = new_state.transactions.expect("transactions should exist"); @@ -1509,30 +1477,9 @@ mod tests { ); let base_output = make_output(policy_id, asset_name, None); - let tx1 = TxUTxODeltas { - tx_identifier: TxIdentifier::new(9, 0), - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..base_output.clone() - }], - }; - let tx2 = TxUTxODeltas { - tx_identifier: TxIdentifier::new(8, 0), - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..base_output.clone() - }], - }; - let tx3 = TxUTxODeltas { - tx_identifier: TxIdentifier::new(7, 0), - inputs: Vec::new(), - outputs: vec![TxOutput { - utxo_identifier: UTxOIdentifier::new(TxHash::default(), 0), - ..base_output - }], - }; + let tx1 = make_tx_utxo_deltas(TxIdentifier::new(9, 0), vec![], vec![base_output.clone()]); + let tx2 = make_tx_utxo_deltas(TxIdentifier::new(8, 0), vec![], vec![base_output.clone()]); + let tx3 = make_tx_utxo_deltas(TxIdentifier::new(7, 0), vec![], vec![base_output]); let new_state = state.handle_transactions(&[tx1, tx2, tx3], ®istry).unwrap(); let txs = new_state.transactions.expect("transactions should exist"); diff --git a/modules/genesis_bootstrapper/src/genesis_bootstrapper.rs b/modules/genesis_bootstrapper/src/genesis_bootstrapper.rs index 2305f878..aa747131 100644 --- a/modules/genesis_bootstrapper/src/genesis_bootstrapper.rs +++ b/modules/genesis_bootstrapper/src/genesis_bootstrapper.rs @@ -20,7 +20,7 @@ use pallas::ledger::configs::{ byron::{genesis_utxos, GenesisFile as ByronGenesisFile}, shelley::GenesisFile as ShelleyGenesisFile, }; -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use tracing::{error, info, info_span, Instrument}; const DEFAULT_STARTUP_TOPIC: &str = "cardano.sequence.start"; @@ -171,6 +171,10 @@ impl GenesisBootstrapper { tx_identifier, inputs: Vec::new(), outputs: vec![tx_output], + vkey_hashes_needed: HashSet::new(), + script_hashes_needed: HashSet::new(), + vkey_hashes_provided: vec![], + script_hashes_provided: vec![], }); total_allocated += amount; } diff --git a/modules/tx_unpacker/Cargo.toml b/modules/tx_unpacker/Cargo.toml index e548620a..3842dc84 100644 --- a/modules/tx_unpacker/Cargo.toml +++ b/modules/tx_unpacker/Cargo.toml @@ -23,6 +23,12 @@ serde = { workspace = true } serde_json = { workspace = true } test-case = "3.3.1" tokio = { workspace = true } +cryptoxide = "0.5.1" +thiserror = "2.0.17" + +[dev-dependencies] +quickcheck = "1.0.3" +quickcheck_macros = "1.1.0" [lib] path = "src/tx_unpacker.rs" diff --git a/modules/utxo_state/src/crypto/ed25519.rs b/modules/tx_unpacker/src/crypto/ed25519.rs similarity index 100% rename from modules/utxo_state/src/crypto/ed25519.rs rename to modules/tx_unpacker/src/crypto/ed25519.rs diff --git a/modules/tx_unpacker/src/crypto/mod.rs b/modules/tx_unpacker/src/crypto/mod.rs new file mode 100644 index 00000000..a3e7ff2f --- /dev/null +++ b/modules/tx_unpacker/src/crypto/mod.rs @@ -0,0 +1,4 @@ +mod ed25519; +mod utils; + +pub use utils::*; diff --git a/modules/utxo_state/src/crypto/utils.rs b/modules/tx_unpacker/src/crypto/utils.rs similarity index 100% rename from modules/utxo_state/src/crypto/utils.rs rename to modules/tx_unpacker/src/crypto/utils.rs diff --git a/modules/tx_unpacker/src/state.rs b/modules/tx_unpacker/src/state.rs index f9b4c70b..6dfeeb57 100644 --- a/modules/tx_unpacker/src/state.rs +++ b/modules/tx_unpacker/src/state.rs @@ -1,7 +1,7 @@ use crate::validations; use acropolis_common::{ messages::ProtocolParamsMessage, protocol_params::ProtocolParams, - validation::TransactionValidationError, BlockInfo, Era, + validation::TransactionValidationError, BlockInfo, Era, GenesisDelegates, }; use anyhow::Result; @@ -25,6 +25,7 @@ impl State { &self, block_info: &BlockInfo, raw_tx: &[u8], + genesis_delegs: &GenesisDelegates, ) -> Result<(), TransactionValidationError> { match block_info.era { Era::Shelley => { @@ -33,7 +34,12 @@ impl State { "Shelley params are not set".to_string(), )); }; - validations::validate_shelley_tx(raw_tx, shelley_params, block_info.slot) + validations::validate_shelley_tx( + raw_tx, + genesis_delegs, + shelley_params, + block_info.slot, + ) } _ => Ok(()), } diff --git a/modules/tx_unpacker/src/tx_unpacker.rs b/modules/tx_unpacker/src/tx_unpacker.rs index 25cee5c9..e9a11e32 100644 --- a/modules/tx_unpacker/src/tx_unpacker.rs +++ b/modules/tx_unpacker/src/tx_unpacker.rs @@ -1,7 +1,7 @@ //! Acropolis transaction unpacker module for Caryatid //! Unpacks transaction bodies into UTXO events -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use acropolis_common::{ messages::{ @@ -16,7 +16,7 @@ use caryatid_sdk::{module, Context, Subscription}; use config::Config; use futures::future::join_all; use pallas::codec::minicbor::encode; -use pallas::ledger::{traverse, traverse::MultiEraTx}; +use pallas::ledger::traverse::MultiEraTx; use tokio::sync::Mutex; use tracing::{debug, error, info, info_span}; @@ -25,12 +25,21 @@ mod state; mod tx_validation_publisher; mod validations; use tx_validation_publisher::TxValidationPublisher; +mod crypto; #[cfg(test)] mod test_utils; -const DEFAULT_TRANSACTIONS_SUBSCRIBE_TOPIC: &str = "cardano.txs"; -const DEFAULT_PROTOCOL_PARAMS_SUBSCRIBE_TOPIC: &str = "cardano.protocol.parameters"; +const DEFAULT_TRANSACTIONS_SUBSCRIBE_TOPIC: (&str, &str) = + ("transactions-subscribe-topic", "cardano.txs"); +const DEFAULT_PROTOCOL_PARAMS_SUBSCRIBE_TOPIC: (&str, &str) = ( + "protocol-parameters-subscribe-topic", + "cardano.protocol.parameters", +); +const DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC: (&str, &str) = ( + "bootstrapped-subscribe-topic", + "cardano.sequence.bootstrapped", +); const CIP25_METADATA_LABEL: u64 = 721; @@ -59,8 +68,17 @@ impl TxUnpacker { tx_validation_publisher: Option, // subscribers mut txs_sub: Box>, + mut bootstrapped_sub: Box>, mut protocol_params_sub: Box>, ) -> Result<()> { + let (_, bootstrapped_message) = bootstrapped_sub.read().await?; + let genesis = match bootstrapped_message.as_ref() { + Message::Cardano((_, CardanoMessage::GenesisComplete(complete))) => { + complete.values.clone() + } + _ => panic!("Unexpected message in genesis completion topic: {bootstrapped_message:?}"), + }; + loop { let mut state = history.lock().await.get_or_init_with(State::new); let mut current_block: Option = None; @@ -118,22 +136,39 @@ impl TxUnpacker { tx.hash().to_vec().try_into().expect("invalid tx hash length"); let tx_identifier = TxIdentifier::new(block_number, tx_index); - let (inputs, outputs, tx_total_output, error) = - acropolis_codec::map_transaction_inputs_outputs( &tx); - let certs = tx.certs(); - let tx_withdrawals = tx.withdrawals_sorted_set(); + let ( + tx_inputs, + tx_outputs, + tx_total_output, + tx_certs, + tx_withdrawals, + tx_proposal_update, + vkey_witnesses, + native_scripts, + tx_error + ) = acropolis_codec::map_transaction(&tx, raw_tx, tx_identifier, network_id.clone(), block.era); let mut props = None; let mut votes = None; + let (vkey_hashes_needed, script_hashes_needed) = Self::get_vkey_script_needed( + &tx_certs, + &tx_withdrawals, + &tx_proposal_update, + ); + let (vkey_hashes_provided, script_hashes_provided) = Self::get_vkey_script_provided( + &vkey_witnesses, + &native_scripts, + ); + // sum up total output lovelace for a block total_output += tx_total_output; if tracing::enabled!(tracing::Level::DEBUG) { debug!("Decoded tx with inputs={}, outputs={}, certs={}, total_output={}", - inputs.len(), outputs.len(), certs.len(), total_output); + tx_inputs.len(), tx_outputs.len(), tx_certs.len(), tx_total_output); } - if let Some(error) = error { + if let Some(error) = tx_error { error!( "Errors decoding transaction {tx_hash}: {error}" ); @@ -143,8 +178,12 @@ impl TxUnpacker { // Group deltas by tx utxo_deltas.push(TxUTxODeltas { tx_identifier, - inputs, - outputs, + inputs: tx_inputs, + outputs: tx_outputs, + vkey_hashes_needed, + script_hashes_needed, + vkey_hashes_provided, + script_hashes_provided, }); } @@ -180,84 +219,17 @@ impl TxUnpacker { } if publish_certificates_topic.is_some() { - for (cert_index, cert) in certs.iter().enumerate() { - match acropolis_codec::map_certificate( - cert, - tx_identifier, - cert_index, - network_id.clone(), - ) { - Ok(tx_cert) => { - certificates.push(tx_cert); - } - Err(_e) => { - // TODO error unexpected - //error!("{e}"); - } - } - } + certificates.extend(tx_certs); } if publish_withdrawals_topic.is_some() { - for (key, value) in tx_withdrawals { - match StakeAddress::from_binary(key) { - Ok(stake_address) => { - withdrawals.push(Withdrawal { - address: stake_address, - value, - tx_identifier, - }); - } - Err(e) => error!("Bad stake address: {e:#}"), - } - } + withdrawals.extend(tx_withdrawals); } - if publish_governance_procedures_topic.is_some() { - //Self::decode_legacy_updates(&mut legacy_update_proposals, &block, &raw_tx); - if block.era >= Era::Shelley && block.era < Era::Babbage { - if let Ok(alonzo) = MultiEraTx::decode_for_era( - traverse::Era::Alonzo, - raw_tx, - ) { - if let Some(update) = alonzo.update() { - if let Some(alonzo_update) = update.as_alonzo() { - match acropolis_codec::map_alonzo_update( - alonzo_update, - ) { - Ok(proposals) => { - alonzo_babbage_update_proposals - .push(proposals) - } - Err(e) => error!( - "Cannot decode alonzo update: {e}" - ), - } - } - } - } - } else if block.era >= Era::Babbage && block.era < Era::Conway { - if let Ok(babbage) = MultiEraTx::decode_for_era( - traverse::Era::Babbage, - raw_tx, - ) { - if let Some(update) = babbage.update() { - if let Some(babbage_update) = update.as_babbage() { - match acropolis_codec::map_babbage_update( - babbage_update, - ) { - Ok(proposals) => { - alonzo_babbage_update_proposals - .push(proposals) - } - Err(e) => error!( - "Cannot decode babbage update: {e}" - ), - } - } - } - } - } + if publish_governance_procedures_topic.is_some() { + if let Some(proposal_update) = tx_proposal_update { + alonzo_babbage_update_proposals.push(proposal_update); + } } if let Some(conway) = tx.as_conway() { @@ -452,7 +424,9 @@ impl TxUnpacker { let tx_index = tx_index as u16; // Validate transaction - if let Err(e) = state.validate_transaction(block, raw_tx) { + if let Err(e) = + state.validate_transaction(block, raw_tx, &genesis.genesis_delegs) + { tx_errors.push((tx_index, e)); } } @@ -514,16 +488,22 @@ impl TxUnpacker { // Subscribers let transactions_subscribe_topic = config - .get_string("subscribe-topic") - .unwrap_or(DEFAULT_TRANSACTIONS_SUBSCRIBE_TOPIC.to_string()); + .get_string(DEFAULT_TRANSACTIONS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_TRANSACTIONS_SUBSCRIBE_TOPIC.1.to_string()); info!("Creating subscriber on '{transactions_subscribe_topic}'"); + let bootstrapped_subscribe_topic = config + .get_string(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating subscriber on '{bootstrapped_subscribe_topic}'"); + let protocol_params_subscribe_topic = config - .get_string("protocol-params-subscribe-topic") - .unwrap_or(DEFAULT_PROTOCOL_PARAMS_SUBSCRIBE_TOPIC.to_string()); + .get_string(DEFAULT_PROTOCOL_PARAMS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_PROTOCOL_PARAMS_SUBSCRIBE_TOPIC.1.to_string()); info!("Creating subscriber on '{protocol_params_subscribe_topic}'"); let txs_sub = context.subscribe(&transactions_subscribe_topic).await?; + let bootstrapped_sub = context.subscribe(&bootstrapped_subscribe_topic).await?; let protocol_params_sub = context.subscribe(&protocol_params_subscribe_topic).await?; let network_id: NetworkId = @@ -549,6 +529,7 @@ impl TxUnpacker { publish_block_txs_topic, tx_validation_publisher, txs_sub, + bootstrapped_sub, protocol_params_sub, ) .await @@ -558,6 +539,82 @@ impl TxUnpacker { Ok(()) } + /// Get VKey Witnesses needed for transaction + /// Get Scripts needed for transaction + /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L274 + /// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L226 + /// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L103 + /// + /// VKey Witnesses needed + /// 1. UTxO authors: keys that own the UTxO being spent + /// 2. Certificate authors: keys authorizing certificates + /// 3. Pool owners: owners that must sign pool registration + /// 4. Withdrawal authors: keys authorizing reward withdrawals + /// 5. Governance authors: keys authorizing governance actions (e.g. protocol update) + /// + /// Script Witnesses needed + /// 1. Input scripts: scripts locking UTxO being spent + /// 2. Withdrawal scripts: scripts controlling reward accounts + /// 3. Certificate scripts: scripts in certificate credentials. + /// + /// NOTE: + /// This doesn't count `inputs` + /// which will be considered in the utxos_state + fn get_vkey_script_needed( + certs: &[TxCertificateWithPos], + withdrawals: &[Withdrawal], + proposal_update: &Option, + ) -> (HashSet, HashSet) { + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + // for each certificate, get the required vkey and script hashes + for cert_with_pos in certs.iter() { + let (v, s) = cert_with_pos.cert.get_cert_authors(); + vkey_hashes.extend(v); + script_hashes.extend(s); + } + + // for each withdrawal, get the required vkey and script hashes + for withdrawal in withdrawals.iter() { + match withdrawal.address.credential { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(vkey_hash); + } + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(script_hash); + } + } + } + + // for each governance action, get the required vkey hashes + if let Some(proposal_update) = proposal_update.as_ref() { + for (genesis_key, _) in proposal_update.proposals.iter() { + vkey_hashes.insert(*genesis_key); + } + } + + (vkey_hashes, script_hashes) + } + + fn get_vkey_script_provided( + vkey_witnesses: &[VKeyWitness], + native_scripts: &[NativeScript], + ) -> (Vec, Vec) { + let mut vkey_hashes = Vec::new(); + let mut script_hashes = Vec::new(); + + for vkey_witness in vkey_witnesses.iter() { + vkey_hashes.push(vkey_witness.key_hash()); + } + + for native_script in native_scripts.iter() { + script_hashes.push(native_script.compute_hash()); + } + + (vkey_hashes, script_hashes) + } + /// Check for synchronisation fn check_sync(expected: &Option, actual: &BlockInfo) { if let Some(ref block) = expected { diff --git a/modules/tx_unpacker/src/validations/mod.rs b/modules/tx_unpacker/src/validations/mod.rs index 4ee75ed2..eaa0644b 100644 --- a/modules/tx_unpacker/src/validations/mod.rs +++ b/modules/tx_unpacker/src/validations/mod.rs @@ -1,33 +1,38 @@ use acropolis_common::{ protocol_params::ShelleyParams, validation::{Phase1ValidationError, TransactionValidationError}, + Era, GenesisDelegates, TxHash, }; use anyhow::Result; -use pallas::ledger::traverse::{self, Era as PallasEra, MultiEraTx}; +use pallas::ledger::traverse::{Era as PallasEra, MultiEraTx}; mod shelley; pub fn validate_shelley_tx( raw_tx: &[u8], + genesis_delegs: &GenesisDelegates, shelley_params: &ShelleyParams, current_slot: u64, ) -> Result<(), TransactionValidationError> { - let tx = MultiEraTx::decode_for_era(traverse::Era::Shelley, raw_tx) - .map_err(|e| TransactionValidationError::CborDecodeError(e.to_string()))?; - let tx_size = tx.size(); - - let mtx = match tx { - MultiEraTx::AlonzoCompatible(mtx, PallasEra::Shelley) => mtx, - _ => { - return Err(TransactionValidationError::MalformedTransaction( - "Not a Shelley transaction".to_string(), - )); + let tx = MultiEraTx::decode_for_era(PallasEra::Shelley, raw_tx).map_err(|e| { + TransactionValidationError::CborDecodeError { + era: Era::Shelley, + reason: e.to_string(), } - }; + })?; + let tx_hash = TxHash::from(*tx.hash()); + let tx_size = tx.size() as u32; + + // because we decode_for_era as shelley, which is alonzo compatible. + let mtx = tx.as_alonzo().ok_or_else(|| TransactionValidationError::CborDecodeError { + era: Era::Shelley, + reason: "Not Alonzo-compatible".to_string(), + })?; - shelley::tx::validate_shelley_tx(&mtx, tx_size as u32, shelley_params, current_slot) - .map_err(|e| *e)?; - shelley::utxo::validate_shelley_tx(&mtx, shelley_params) + shelley::tx::validate(mtx, tx_size, shelley_params, current_slot).map_err(|e| *e)?; + shelley::utxo::validate(mtx, shelley_params) .map_err(|e| Phase1ValidationError::UTxOValidationError(*e))?; + shelley::utxow::validate(mtx, tx_hash, genesis_delegs, shelley_params.update_quorum) + .map_err(|e| Phase1ValidationError::UTxOWValidationError(*e))?; Ok(()) } diff --git a/modules/tx_unpacker/src/validations/shelley/mod.rs b/modules/tx_unpacker/src/validations/shelley/mod.rs index 907b36f9..93f11b0f 100644 --- a/modules/tx_unpacker/src/validations/shelley/mod.rs +++ b/modules/tx_unpacker/src/validations/shelley/mod.rs @@ -1,2 +1,3 @@ pub mod tx; pub mod utxo; +pub mod utxow; diff --git a/modules/tx_unpacker/src/validations/shelley/tx.rs b/modules/tx_unpacker/src/validations/shelley/tx.rs index 4d22371f..64c9a13e 100644 --- a/modules/tx_unpacker/src/validations/shelley/tx.rs +++ b/modules/tx_unpacker/src/validations/shelley/tx.rs @@ -4,19 +4,19 @@ use acropolis_common::{protocol_params::ShelleyParams, validation::Phase1ValidationError}; use anyhow::Result; use pallas::ledger::primitives::alonzo; - pub type Phase1ValidationResult = Result<(), Box>; -pub fn validate_shelley_tx( +pub fn validate( mtx: &alonzo::MintedTx, tx_size: u32, shelley_params: &ShelleyParams, current_slot: u64, ) -> Phase1ValidationResult { let transaction_body = &mtx.transaction_body; - - validate_time_to_live(mtx, current_slot)?; - validate_fee_too_small_utxo(transaction_body, tx_size, shelley_params)?; + let fee = transaction_body.fee; + let ttl = transaction_body.ttl; + validate_time_to_live(ttl, current_slot)?; + validate_fee_too_small_utxo(fee, tx_size, shelley_params)?; validate_max_tx_size_utxo(tx_size, shelley_params)?; Ok(()) } @@ -24,8 +24,8 @@ pub fn validate_shelley_tx( /// Validate transaction's TTL field /// pass if ttl >= current_slot /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxo.hs#L421 -pub fn validate_time_to_live(tx: &alonzo::MintedTx, current_slot: u64) -> Phase1ValidationResult { - if let Some(ttl) = tx.transaction_body.ttl { +pub fn validate_time_to_live(ttl: Option, current_slot: u64) -> Phase1ValidationResult { + if let Some(ttl) = ttl { if ttl >= current_slot { Ok(()) } else { @@ -35,9 +35,9 @@ pub fn validate_time_to_live(tx: &alonzo::MintedTx, current_slot: u64) -> Phase1 })) } } else { - Err(Box::new(Phase1ValidationError::Other( - "TTL is missing for Shelley Tx".to_string(), - ))) + Err(Box::new(Phase1ValidationError::MalformedTransaction { + errors: vec!["TTL is missing for Shelley Tx".to_string()], + })) } } @@ -46,14 +46,14 @@ pub fn validate_time_to_live(tx: &alonzo::MintedTx, current_slot: u64) -> Phase1 /// minFee = (tx_size_in_bytes * min_a) + min_b + ref_script_fee (this is after Alonzo Era) /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxo.hs#L447 pub fn validate_fee_too_small_utxo( - transaction_body: &alonzo::TransactionBody, + fee: u64, tx_size: u32, shelley_params: &ShelleyParams, ) -> Phase1ValidationResult { let min_fee = shelley_params.min_fee(tx_size); - if transaction_body.fee < min_fee { + if fee < min_fee { Err(Box::new(Phase1ValidationError::FeeTooSmallUTxO { - supplied: transaction_body.fee, + supplied: fee, required: min_fee, })) } else { @@ -109,7 +109,6 @@ mod tests { fn shelley_test((ctx, raw_tx): (TestContext, Vec)) -> Result<(), Phase1ValidationError> { let tx = MultiEraTx::decode_for_era(PallasEra::Shelley, &raw_tx).unwrap(); let mtx = tx.as_alonzo().unwrap(); - validate_shelley_tx(mtx, tx.size() as u32, &ctx.shelley_params, ctx.current_slot) - .map_err(|e| *e) + validate(mtx, tx.size() as u32, &ctx.shelley_params, ctx.current_slot).map_err(|e| *e) } } diff --git a/modules/tx_unpacker/src/validations/shelley/utxo.rs b/modules/tx_unpacker/src/validations/shelley/utxo.rs index cc7dadcd..fe679ac8 100644 --- a/modules/tx_unpacker/src/validations/shelley/utxo.rs +++ b/modules/tx_unpacker/src/validations/shelley/utxo.rs @@ -37,10 +37,7 @@ fn compute_min_lovelace(value: &alonzo::Value, shelley_params: &ShelleyParams) - pub type UTxOValidationResult = Result<(), Box>; -pub fn validate_shelley_tx( - mtx: &alonzo::MintedTx, - shelley_params: &ShelleyParams, -) -> UTxOValidationResult { +pub fn validate(mtx: &alonzo::MintedTx, shelley_params: &ShelleyParams) -> UTxOValidationResult { let network_id = shelley_params.network_id.clone(); let transaction_body = &mtx.transaction_body; @@ -220,6 +217,6 @@ mod tests { fn shelley_test((ctx, raw_tx): (TestContext, Vec)) -> Result<(), UTxOValidationError> { let tx = MultiEraTx::decode_for_era(PallasEra::Shelley, &raw_tx).unwrap(); let mtx = tx.as_alonzo().unwrap(); - validate_shelley_tx(mtx, &ctx.shelley_params).map_err(|e| *e) + validate(mtx, &ctx.shelley_params).map_err(|e| *e) } } diff --git a/modules/tx_unpacker/src/validations/shelley/utxow.rs b/modules/tx_unpacker/src/validations/shelley/utxow.rs new file mode 100644 index 00000000..44b018ba --- /dev/null +++ b/modules/tx_unpacker/src/validations/shelley/utxow.rs @@ -0,0 +1,318 @@ +//! Shelley era UTxOW Rules +//! Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L278 +#![allow(dead_code)] + +use std::collections::HashSet; + +use crate::crypto::verify_ed25519_signature; +use acropolis_common::{ + validation::UTxOWValidationError, AlonzoBabbageUpdateProposal, GenesisDelegates, KeyHash, + NativeScript, ScriptHash, ShelleyAddressPaymentPart, StakeCredential, TxCertificate, + TxCertificateWithPos, TxHash, UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, +}; +use anyhow::Result; +use pallas::ledger::primitives::alonzo; + +fn has_mir_certificate(mtx: &alonzo::MintedTx) -> bool { + mtx.transaction_body + .certificates + .as_ref() + .map(|certs| { + certs + .iter() + .any(|cert| matches!(cert, alonzo::Certificate::MoveInstantaneousRewardsCert(_))) + }) + .unwrap_or(false) +} + +/// This function extracts required VKey Hashes +/// from TxCert (pallas type) +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 +fn get_cert_authors( + cert_with_pos: &TxCertificateWithPos, +) -> (HashSet, HashSet) { + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + let mut parse_cred = |cred: &StakeCredential| match cred { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(*vkey_hash); + } + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(*script_hash); + } + }; + + match &cert_with_pos.cert { + // Deregistration requires witness from stake credential + TxCertificate::StakeDeregistration(addr) => { + parse_cred(&addr.credential); + } + // Delegation requries withness from delegator + TxCertificate::StakeDelegation(deleg) => { + parse_cred(&deleg.stake_address.credential); + } + // Pool registration requires witness from pool cold key and owners + TxCertificate::PoolRegistration(pool_reg) => { + vkey_hashes.insert(*pool_reg.operator); + vkey_hashes + .extend(pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>()); + } + // Pool retirement requires withness from pool cold key + TxCertificate::PoolRetirement(retirement) => { + vkey_hashes.insert(*retirement.operator); + } + // Genesis delegation requires witness from genesis key + TxCertificate::GenesisKeyDelegation(gen_deleg) => { + vkey_hashes.insert(*gen_deleg.genesis_delegate_hash); + } + _ => {} + } + + (vkey_hashes, script_hashes) +} + +/// Get VKey Witnesses needed for transaction +/// Get Scripts needed for transaction +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L274 +/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L226 +/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L103 +/// +/// VKey Witnesses needed +/// 1. UTxO authors: keys that own the UTxO being spent +/// 2. Certificate authors: keys authorizing certificates +/// 3. Pool owners: owners that must sign pool registration +/// 4. Withdrawal authors: keys authorizing reward withdrawals +/// 5. Governance authors: keys authorizing governance actions (e.g. protocol update) +/// +/// Script Witnesses needed +/// 1. Input scripts: scripts locking UTxO being spent +/// 2. Withdrawal scripts: scripts controlling reward accounts +/// 3. Certificate scripts: scripts in certificate credentials. +pub fn get_vkey_script_needed( + inputs: &[UTxOIdentifier], + certificates: &[TxCertificateWithPos], + withdrawals: &[Withdrawal], + alonzo_babbage_update_proposal: &Option, + lookup_utxo: F, +) -> (HashSet, HashSet) +where + F: Fn(&UTxOIdentifier) -> Result>, +{ + let mut vkey_hashes = HashSet::new(); + let mut script_hashes = HashSet::new(); + + // for each UTxO, extract the needed vkey and script hashes + for utxo in inputs.iter() { + if let Ok(Some(utxo)) = lookup_utxo(utxo) { + // NOTE: + // Need to check inputs from byron bootstrap addresses + // with bootstrap witnesses + if let Some(payment_part) = utxo.address.get_payment_part() { + match payment_part { + ShelleyAddressPaymentPart::PaymentKeyHash(payment_key_hash) => { + vkey_hashes.insert(payment_key_hash); + } + ShelleyAddressPaymentPart::ScriptHash(script_hash) => { + script_hashes.insert(script_hash); + } + } + } + } + } + + // for each certificate, get the required vkey and script hashes + for cert in certificates.iter() { + let (v, s) = get_cert_authors(cert); + vkey_hashes.extend(v); + script_hashes.extend(s); + } + + // for each withdrawal, get the required vkey and script hashes + for withdrawal in withdrawals.iter() { + match withdrawal.address.credential { + StakeCredential::AddrKeyHash(vkey_hash) => { + vkey_hashes.insert(vkey_hash); + } + StakeCredential::ScriptHash(script_hash) => { + script_hashes.insert(script_hash); + } + } + } + + // for each governance action, get the required vkey hashes + if let Some(update) = alonzo_babbage_update_proposal { + for (genesis_key, _) in update.proposals.iter() { + vkey_hashes.insert(*genesis_key); + } + } + + (vkey_hashes, script_hashes) +} + +/// Validate Native Scripts from Transaction witnesses +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L373 +pub fn validate_failed_native_scripts( + native_scripts: &[NativeScript], + vkey_hashes_provided: &HashSet, + low_bnd: Option, + upp_bnd: Option, +) -> Result<(), Box> { + for native_script in native_scripts { + if !native_script.eval(vkey_hashes_provided, low_bnd, upp_bnd) { + return Err(Box::new( + UTxOWValidationError::ScriptWitnessNotValidatingUTXOW { + script_hash: native_script.compute_hash(), + }, + )); + } + } + + Ok(()) +} + +/// Validate all needed scripts are provided in witnesses +/// No missing, no extra +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L386 +pub fn validate_missing_extra_scripts( + script_hashes_needed: &HashSet, + native_scripts: &[NativeScript], +) -> Result<(), Box> { + // check for missing & extra scripts + let mut scripts_used = + native_scripts.iter().map(|script| (false, script.compute_hash())).collect::>(); + for script_hash in script_hashes_needed.iter() { + if let Some((used, _)) = scripts_used.iter_mut().find(|(u, h)| !(*u) && script_hash.eq(h)) { + *used = true; + } else { + return Err(Box::new( + UTxOWValidationError::MissingScriptWitnessesUTxOW { + script_hash: *script_hash, + }, + )); + } + } + + for (used, script_hash) in scripts_used.iter() { + if !*used { + return Err(Box::new( + UTxOWValidationError::ExtraneousScriptWitnessesUTXOW { + script_hash: *script_hash, + }, + )); + } + } + Ok(()) +} + +/// Validate that all vkey witnesses signatures +/// are verified +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L401 +pub fn validate_verified_wits( + vkey_witnesses: &[VKeyWitness], + tx_hash: TxHash, +) -> Result<(), Box> { + for vkey_witness in vkey_witnesses.iter() { + if !verify_ed25519_signature(vkey_witness, tx_hash.as_ref()) { + return Err(Box::new(UTxOWValidationError::InvalidWitnessesUTxOW { + key_hash: vkey_witness.key_hash(), + witness: vkey_witness.clone(), + })); + } + } + Ok(()) +} + +/// Validate that all required witnesses are provided +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L424 +pub fn validate_needed_witnesses( + vkey_hashes_needed: &HashSet, + vkey_hashes_provided: &HashSet, +) -> Result<(), Box> { + for vkey_hash in vkey_hashes_needed.iter() { + if !vkey_hashes_provided.contains(vkey_hash) { + return Err(Box::new(UTxOWValidationError::MissingVKeyWitnessesUTxOW { + key_hash: *vkey_hash, + })); + } + } + Ok(()) +} + +/// Validate genesis keys signatures for MIR certificate +/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L463 +pub fn validate_mir_insufficient_genesis_sigs( + vkey_hashes_provided: &HashSet, + genesis_delegs: &GenesisDelegates, + update_quorum: u32, +) -> Result<(), Box> { + let genesis_delegate_hashes = + genesis_delegs.as_ref().values().map(|delegate| delegate.delegate).collect::>(); + + // genSig := genDelegates ∩ witsKeyHashes + let genesis_sigs = + genesis_delegate_hashes.intersection(vkey_hashes_provided).copied().collect::>(); + + // Check: |genSig| ≥ Quorum + // If insufficient, report the signatures that were found (not the missing ones) + if genesis_sigs.len() < update_quorum as usize { + return Err(Box::new( + UTxOWValidationError::MIRInsufficientGenesisSigsUTXOW { + gensis_keys: genesis_sigs, + quorum: update_quorum, + }, + )); + } + + Ok(()) +} + +pub fn validate( + mtx: &alonzo::MintedTx, + tx_hash: TxHash, + genesis_delegs: &GenesisDelegates, + update_quorum: u32, +) -> Result<(), Box> { + let transaction_body = &mtx.transaction_body; + let transaction_witness_set = &mtx.transaction_witness_set; + + // extract vkey_witnesses and native_scripts + let vkey_witnesses = transaction_witness_set + .vkeywitness + .as_ref() + .map(|witnesses| acropolis_codec::map_vkey_witnesses(witnesses)) + .unwrap_or_default(); + let native_scripts = transaction_witness_set + .native_script + .as_ref() + .map(|scripts| acropolis_codec::map_native_scripts(scripts)) + .unwrap_or_default(); + + // Extract vkey hashes from vkey_witnesses + let vkey_hashes_provided = vkey_witnesses.iter().map(|w| w.key_hash()).collect::>(); + + // validate native scripts + validate_failed_native_scripts( + &native_scripts, + &vkey_hashes_provided, + transaction_body.validity_interval_start, + transaction_body.ttl, + )?; + + // validate vkey witnesses signatures + validate_verified_wits(&vkey_witnesses, tx_hash)?; + + // NOTE: + // need to validate metadata + + // validate mir certificate genesis sig + if has_mir_certificate(mtx) { + validate_mir_insufficient_genesis_sigs( + &vkey_hashes_provided, + genesis_delegs, + update_quorum, + )?; + } + + Ok(()) +} diff --git a/modules/utxo_state/Cargo.toml b/modules/utxo_state/Cargo.toml index 273e0abc..0d784400 100644 --- a/modules/utxo_state/Cargo.toml +++ b/modules/utxo_state/Cargo.toml @@ -10,7 +10,6 @@ license = "Apache-2.0" [dependencies] acropolis_common = { path = "../../common" } -acropolis_codec = { path = "../../codec" } caryatid_sdk = { workspace = true } @@ -19,18 +18,12 @@ async-trait = "0.1" config = { workspace = true } dashmap = { workspace = true } fjall = "2.7.0" +serde = { workspace = true } serde_cbor = "0.11.2" sled = "0.34.7" tokio = { workspace = true } tracing = { workspace = true } -pallas = { workspace = true } -cryptoxide = "0.5.1" -thiserror = "2.0.17" hex = { workspace = true } -[dev-dependencies] -quickcheck = "1.0.3" -quickcheck_macros = "1.1.0" - [lib] path = "src/utxo_state.rs" diff --git a/modules/utxo_state/src/crypto/mod.rs b/modules/utxo_state/src/crypto/mod.rs deleted file mode 100644 index eee2ff54..00000000 --- a/modules/utxo_state/src/crypto/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod ed25519; -pub mod utils; - -pub use utils::*; diff --git a/modules/utxo_state/src/state.rs b/modules/utxo_state/src/state.rs index f70fafc3..5a68c3dd 100644 --- a/modules/utxo_state/src/state.rs +++ b/modules/utxo_state/src/state.rs @@ -394,6 +394,8 @@ struct AddressTxMap { // -- Tests -- #[cfg(test)] mod tests { + use std::collections::HashSet; + use super::*; use crate::InMemoryImmutableUTXOStore; use acropolis_common::{ @@ -477,6 +479,10 @@ mod tests { tx_identifier: Default::default(), inputs: vec![], outputs: vec![output.clone()], + vkey_hashes_needed: HashSet::new(), + script_hashes_needed: HashSet::new(), + vkey_hashes_provided: vec![], + script_hashes_provided: vec![], }], }; @@ -847,6 +853,10 @@ mod tests { tx_identifier: Default::default(), inputs: vec![], outputs: vec![output.clone()], + vkey_hashes_needed: HashSet::new(), + script_hashes_needed: HashSet::new(), + vkey_hashes_provided: vec![], + script_hashes_provided: vec![], }], }; @@ -863,6 +873,10 @@ mod tests { tx_identifier: Default::default(), inputs: vec![input], outputs: vec![], + vkey_hashes_needed: HashSet::new(), + script_hashes_needed: HashSet::new(), + vkey_hashes_provided: vec![], + script_hashes_provided: vec![], }], }; diff --git a/modules/utxo_state/src/test_utils.rs b/modules/utxo_state/src/test_utils.rs new file mode 100644 index 00000000..e976d25d --- /dev/null +++ b/modules/utxo_state/src/test_utils.rs @@ -0,0 +1,62 @@ +use acropolis_common::{protocol_params::ShelleyParams, Slot}; + +#[derive(Debug, serde::Deserialize, serde::Serialize)] +pub struct TestContextJson { + pub shelley_params: ShelleyParams, + pub current_slot: Slot, +} + +#[derive(Debug)] +pub struct TestContext { + pub shelley_params: ShelleyParams, + pub current_slot: Slot, +} + +impl From for TestContext { + fn from(json: TestContextJson) -> Self { + Self { + shelley_params: json.shelley_params, + current_slot: json.current_slot, + } + } +} +#[macro_export] +macro_rules! include_cbor { + ($filepath:expr) => { + hex::decode(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/", + $filepath, + ))) + .expect(concat!("invalid cbor file: ", $filepath)) + }; +} + +#[macro_export] +macro_rules! include_context { + ($filepath:expr) => { + serde_json::from_str::<$crate::test_utils::TestContextJson>(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/data/", + $filepath, + ))) + .expect(concat!("invalid context file: ", $filepath)) + .into() + }; +} + +#[macro_export] +macro_rules! validation_fixture { + ($hash:literal) => { + ( + $crate::include_context!(concat!($hash, "/context.json")), + $crate::include_cbor!(concat!($hash, "/tx.cbor")), + ) + }; + ($hash:literal, $variant:literal) => { + ( + $crate::include_context!(concat!($hash, "/", "/context.json")), + $crate::include_cbor!(concat!($hash, "/", $variant, ".cbor")), + ) + }; +} diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index cdf7a209..7c6b3c71 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -20,6 +20,8 @@ use tracing::{error, info, info_span, Instrument}; mod state; use state::{ImmutableUTXOStore, State}; +mod test_utils; + mod address_delta_publisher; mod volatile_index; use address_delta_publisher::AddressDeltaPublisher; @@ -38,7 +40,6 @@ use fjall_async_immutable_utxo_store::FjallAsyncImmutableUTXOStore; mod fake_immutable_utxo_store; use fake_immutable_utxo_store::FakeImmutableUTXOStore; -mod crypto; mod validations; const DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = diff --git a/modules/utxo_state/src/validations/mod.rs b/modules/utxo_state/src/validations/mod.rs index bf29fcef..78896b5a 100644 --- a/modules/utxo_state/src/validations/mod.rs +++ b/modules/utxo_state/src/validations/mod.rs @@ -1,24 +1,18 @@ +use std::collections::HashSet; + use acropolis_common::{ validation::{Phase1ValidationError, TransactionValidationError}, - AlonzoBabbageUpdateProposal, GenesisDelegates, NativeScript, TxCertificateWithPos, TxHash, - UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, + KeyHash, ScriptHash, UTXOValue, UTxOIdentifier, }; use anyhow::Result; mod shelley; -#[allow(clippy::too_many_arguments)] pub fn validate_shelley_tx( - tx_hash: TxHash, inputs: &[UTxOIdentifier], - certificates: &[TxCertificateWithPos], - withdrawals: &[Withdrawal], - alonzo_babbage_update_proposal: &Option, - vkey_witnesses: &[VKeyWitness], - native_scripts: &[NativeScript], - low_bnd: Option, - upp_bnd: Option, - genesis_delegs: &GenesisDelegates, - update_quorum: u32, + vkey_hashes_needed: &mut HashSet, + script_hashes_needed: &mut HashSet, + vkey_hashes_provided: &[KeyHash], + script_hashes_provided: &[ScriptHash], lookup_utxo: F, ) -> Result<(), TransactionValidationError> where @@ -27,17 +21,11 @@ where shelley::utxo::validate(inputs, &lookup_utxo) .map_err(|e| Phase1ValidationError::UTxOValidationError(*e))?; shelley::utxow::validate( - tx_hash, inputs, - certificates, - withdrawals, - alonzo_babbage_update_proposal, - vkey_witnesses, - native_scripts, - low_bnd, - upp_bnd, - genesis_delegs, - update_quorum, + vkey_hashes_needed, + script_hashes_needed, + vkey_hashes_provided, + script_hashes_provided, &lookup_utxo, ) .map_err(|e| Phase1ValidationError::UTxOWValidationError(*e))?; diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index 1d6a9653..8180e242 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -4,141 +4,20 @@ use std::collections::HashSet; -use crate::crypto::verify_ed25519_signature; use acropolis_common::{ - validation::UTxOWValidationError, AlonzoBabbageUpdateProposal, GenesisDelegates, KeyHash, - NativeScript, ScriptHash, ShelleyAddressPaymentPart, StakeCredential, TxCertificate, - TxCertificateWithPos, TxHash, UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, + validation::UTxOWValidationError, GenesisDelegates, KeyHash, ScriptHash, + ShelleyAddressPaymentPart, TxCertificate, TxCertificateWithPos, UTXOValue, UTxOIdentifier, }; use anyhow::Result; -use pallas::ledger::primitives::alonzo; -fn get_vkey_witnesses(tx: &alonzo::MintedTx) -> Vec { - tx.transaction_witness_set - .vkeywitness - .as_ref() - .map(|witnesses| { - witnesses - .iter() - .map(|witness| VKeyWitness::new(witness.vkey.to_vec(), witness.signature.to_vec())) - .collect() - }) - .unwrap_or_default() -} - -pub fn eval_native_script( - native_script: &NativeScript, - vkey_hashes_provided: &HashSet, - low_bnd: Option, - upp_bnd: Option, -) -> bool { - match native_script { - NativeScript::ScriptAll(scripts) => scripts - .iter() - .all(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)), - NativeScript::ScriptAny(scripts) => scripts - .iter() - .any(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)), - NativeScript::ScriptPubkey(hash) => vkey_hashes_provided.contains(hash), - NativeScript::ScriptNOfK(val, scripts) => { - let count = scripts - .iter() - .map(|script| eval_native_script(script, vkey_hashes_provided, low_bnd, upp_bnd)) - .fold(0, |x, y| x + y as u32); - count >= *val - } - NativeScript::InvalidBefore(val) => { - match low_bnd { - Some(time) => *val >= time, - None => false, // as per mary-ledger.pdf, p.20 - } - } - NativeScript::InvalidHereafter(val) => { - match upp_bnd { - Some(time) => *val <= time, - None => false, // as per mary-ledger.pdf, p.20 - } - } - } -} - -/// This function extracts required VKey Hashes -/// from TxCert (pallas type) -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 -fn get_cert_authors( - cert_with_pos: &TxCertificateWithPos, -) -> (HashSet, HashSet) { - let mut vkey_hashes = HashSet::new(); - let mut script_hashes = HashSet::new(); - - let mut parse_cred = |cred: &StakeCredential| match cred { - StakeCredential::AddrKeyHash(vkey_hash) => { - vkey_hashes.insert(*vkey_hash); - } - StakeCredential::ScriptHash(script_hash) => { - script_hashes.insert(*script_hash); - } - }; - - match &cert_with_pos.cert { - // Deregistration requires witness from stake credential - TxCertificate::StakeDeregistration(addr) => { - parse_cred(&addr.credential); - } - // Delegation requries withness from delegator - TxCertificate::StakeDelegation(deleg) => { - parse_cred(&deleg.stake_address.credential); - } - // Pool registration requires witness from pool cold key and owners - TxCertificate::PoolRegistration(pool_reg) => { - vkey_hashes.insert(*pool_reg.operator); - vkey_hashes - .extend(pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>()); - } - // Pool retirement requires withness from pool cold key - TxCertificate::PoolRetirement(retirement) => { - vkey_hashes.insert(*retirement.operator); - } - // Genesis delegation requires witness from genesis key - TxCertificate::GenesisKeyDelegation(gen_deleg) => { - vkey_hashes.insert(*gen_deleg.genesis_delegate_hash); - } - _ => {} - } - - (vkey_hashes, script_hashes) -} - -/// Get VKey Witnesses needed for transaction -/// Get Scripts needed for transaction -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L274 -/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L226 -/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L103 -/// -/// VKey Witnesses needed -/// 1. UTxO authors: keys that own the UTxO being spent -/// 2. Certificate authors: keys authorizing certificates -/// 3. Pool owners: owners that must sign pool registration -/// 4. Withdrawal authors: keys authorizing reward withdrawals -/// 5. Governance authors: keys authorizing governance actions (e.g. protocol update) -/// -/// Script Witnesses needed -/// 1. Input scripts: scripts locking UTxO being spent -/// 2. Withdrawal scripts: scripts controlling reward accounts -/// 3. Certificate scripts: scripts in certificate credentials. -pub fn get_vkey_script_needed( +pub fn get_vkey_script_needed_from_inputs( inputs: &[UTxOIdentifier], - certificates: &[TxCertificateWithPos], - withdrawals: &[Withdrawal], - alonzo_babbage_update_proposal: &Option, + vkey_hashes_needed: &mut HashSet, + script_hashes_needed: &mut HashSet, lookup_utxo: F, -) -> (HashSet, HashSet) -where +) where F: Fn(&UTxOIdentifier) -> Result>, { - let mut vkey_hashes = HashSet::new(); - let mut script_hashes = HashSet::new(); - // for each UTxO, extract the needed vkey and script hashes for utxo in inputs.iter() { if let Ok(Some(utxo)) = lookup_utxo(utxo) { @@ -148,64 +27,15 @@ where if let Some(payment_part) = utxo.address.get_payment_part() { match payment_part { ShelleyAddressPaymentPart::PaymentKeyHash(payment_key_hash) => { - vkey_hashes.insert(payment_key_hash); + vkey_hashes_needed.insert(payment_key_hash); } ShelleyAddressPaymentPart::ScriptHash(script_hash) => { - script_hashes.insert(script_hash); + script_hashes_needed.insert(script_hash); } } } } } - - // for each certificate, get the required vkey and script hashes - for cert in certificates.iter() { - let (v, s) = get_cert_authors(cert); - vkey_hashes.extend(v); - script_hashes.extend(s); - } - - // for each withdrawal, get the required vkey and script hashes - for withdrawal in withdrawals.iter() { - match withdrawal.address.credential { - StakeCredential::AddrKeyHash(vkey_hash) => { - vkey_hashes.insert(vkey_hash); - } - StakeCredential::ScriptHash(script_hash) => { - script_hashes.insert(script_hash); - } - } - } - - // for each governance action, get the required vkey hashes - if let Some(update) = alonzo_babbage_update_proposal { - for (genesis_key, _) in update.proposals.iter() { - vkey_hashes.insert(*genesis_key); - } - } - - (vkey_hashes, script_hashes) -} - -/// Validate Native Scripts from Transaction witnesses -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L373 -pub fn validate_failed_native_scripts( - native_scripts: &[NativeScript], - vkey_hashes_provided: &HashSet, - low_bnd: Option, - upp_bnd: Option, -) -> Result<(), Box> { - for native_script in native_scripts { - if !eval_native_script(native_script, vkey_hashes_provided, low_bnd, upp_bnd) { - return Err(Box::new( - UTxOWValidationError::ScriptWitnessNotValidatingUTXOW { - script_hash: native_script.compute_hash(), - }, - )); - } - } - - Ok(()) } /// Validate all needed scripts are provided in witnesses @@ -213,11 +43,12 @@ pub fn validate_failed_native_scripts( /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L386 pub fn validate_missing_extra_scripts( script_hashes_needed: &HashSet, - native_scripts: &[NativeScript], + script_hashes_provided: &[ScriptHash], ) -> Result<(), Box> { - // check for missing & extra scripts let mut scripts_used = - native_scripts.iter().map(|script| (false, script.compute_hash())).collect::>(); + script_hashes_provided.iter().map(|h| (false, h.clone())).collect::>(); + + // check for missing & extra scripts for script_hash in script_hashes_needed.iter() { if let Some((used, _)) = scripts_used.iter_mut().find(|(u, h)| !(*u) && script_hash.eq(h)) { *used = true; @@ -242,29 +73,11 @@ pub fn validate_missing_extra_scripts( Ok(()) } -/// Validate that all vkey witnesses signatures -/// are verified -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L401 -pub fn validate_verified_wits( - vkey_witnesses: &[VKeyWitness], - tx_hash: TxHash, -) -> Result<(), Box> { - for vkey_witness in vkey_witnesses.iter() { - if !verify_ed25519_signature(vkey_witness, tx_hash.as_ref()) { - return Err(Box::new(UTxOWValidationError::InvalidWitnessesUTxOW { - key_hash: vkey_witness.key_hash(), - witness: vkey_witness.clone(), - })); - } - } - Ok(()) -} - /// Validate that all required witnesses are provided /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L424 pub fn validate_needed_witnesses( vkey_hashes_needed: &HashSet, - vkey_hashes_provided: &HashSet, + vkey_hashes_provided: &[KeyHash], ) -> Result<(), Box> { for vkey_hash in vkey_hashes_needed.iter() { if !vkey_hashes_provided.contains(vkey_hash) { @@ -315,58 +128,32 @@ pub fn validate_mir_insufficient_genesis_sigs( Ok(()) } -#[allow(clippy::too_many_arguments)] pub fn validate( - tx_hash: TxHash, inputs: &[UTxOIdentifier], - certificates: &[TxCertificateWithPos], - withdrawals: &[Withdrawal], - alonzo_babbage_update_proposal: &Option, - vkey_witnesses: &[VKeyWitness], - native_scripts: &[NativeScript], - low_bnd: Option, - upp_bnd: Option, - genesis_delegs: &GenesisDelegates, - update_quorum: u32, + // Need to include vkey hashes and script hashes + // from inputs + vkey_hashes_needed: &mut HashSet, + script_hashes_needed: &mut HashSet, + vkey_hashes_provided: &[KeyHash], + script_hashes_provided: &[ScriptHash], lookup_utxo: F, ) -> Result<(), Box> where F: Fn(&UTxOIdentifier) -> Result>, { - // Extract required vkey and script hashes - let (vkey_hashes_needed, script_hashes_needed) = get_vkey_script_needed( + // Extract vkey hashes and script hashes from inputs + get_vkey_script_needed_from_inputs( inputs, - certificates, - withdrawals, - alonzo_babbage_update_proposal, + vkey_hashes_needed, + script_hashes_needed, lookup_utxo, ); - // Extract vkey hashes from vkey_witnesses - let vkey_hashes_provided = vkey_witnesses.iter().map(|w| w.key_hash()).collect::>(); - - // validate native scripts - validate_failed_native_scripts(native_scripts, &vkey_hashes_provided, low_bnd, upp_bnd)?; - // validate missing & extra scripts - validate_missing_extra_scripts(&script_hashes_needed, native_scripts)?; - - // validate vkey witnesses signatures - validate_verified_wits(vkey_witnesses, tx_hash)?; + validate_missing_extra_scripts(script_hashes_needed, script_hashes_provided)?; // validate required vkey witnesses are provided - validate_needed_witnesses(&vkey_hashes_needed, &vkey_hashes_provided)?; - - // NOTE: - // need to validate metadata - - // validate mir certificate genesis sig - validate_mir_insufficient_genesis_sigs( - certificates, - &vkey_hashes_provided, - genesis_delegs, - update_quorum, - )?; + validate_needed_witnesses(vkey_hashes_needed, vkey_hashes_provided)?; Ok(()) } From 3f9c12f8284c18e887481d22293dec90af5bcc7e Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 18:47:37 +0100 Subject: [PATCH 10/13] fix: eval script time lock --- common/src/types.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/common/src/types.rs b/common/src/types.rs index d00b1e50..cc0b5c43 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -294,6 +294,9 @@ pub struct TxUTxODeltas { pub script_hashes_needed: HashSet, // From witnesses pub vkey_hashes_provided: Vec, + // NOTE: + // This includes only native scripts + // missing Plutus Scripts pub script_hashes_provided: Vec, } @@ -608,13 +611,13 @@ impl NativeScript { } Self::InvalidBefore(val) => { match low_bnd { - Some(time) => *val >= time, + Some(time) => *val <= time, None => false, // as per mary-ledger.pdf, p.20 } } Self::InvalidHereafter(val) => { match upp_bnd { - Some(time) => *val <= time, + Some(time) => *val >= time, None => false, // as per mary-ledger.pdf, p.20 } } From a0e5edcd030215b74f066648f51286acef4fdb78 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 18:53:49 +0100 Subject: [PATCH 11/13] fix: typo --- common/src/types.rs | 4 ++-- common/src/validation.rs | 8 ++++---- modules/tx_unpacker/src/validations/shelley/utxow.rs | 6 +++--- modules/utxo_state/src/validations/shelley/utxow.rs | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/common/src/types.rs b/common/src/types.rs index cc0b5c43..eddd901f 100644 --- a/common/src/types.rs +++ b/common/src/types.rs @@ -2598,7 +2598,7 @@ impl TxCertificate { Self::StakeDeregistration(addr) => { parse_cred(&addr.credential); } - // Delegation requries withness from delegator + // Delegation requries witness from delegator Self::StakeDelegation(deleg) => { parse_cred(&deleg.stake_address.credential); } @@ -2609,7 +2609,7 @@ impl TxCertificate { pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>(), ); } - // Pool retirement requires withness from pool cold key + // Pool retirement requires witness from pool cold key Self::PoolRetirement(retirement) => { vkey_hashes.insert(*retirement.operator); } diff --git a/common/src/validation.rs b/common/src/validation.rs index 31103c9b..4e388067 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -222,13 +222,13 @@ pub enum UTxOWValidationError { /// **Cause:** Insufficient genesis signatures for MIR Tx #[error( - "Insufficient Genesis Signatures for MIR: gensis_keys={}, count={}, quorum={}", - gensis_keys.iter().map(|k| k.to_string()).collect::>().join(","), - gensis_keys.len(), + "Insufficient Genesis Signatures for MIR: genesis_keys={}, count={}, quorum={}", + genesis_keys.iter().map(|k| k.to_string()).collect::>().join(","), + genesis_keys.len(), quorum )] MIRInsufficientGenesisSigsUTXOW { - gensis_keys: HashSet>, + genesis_keys: HashSet>, quorum: u32, }, diff --git a/modules/tx_unpacker/src/validations/shelley/utxow.rs b/modules/tx_unpacker/src/validations/shelley/utxow.rs index 44b018ba..95fb20c2 100644 --- a/modules/tx_unpacker/src/validations/shelley/utxow.rs +++ b/modules/tx_unpacker/src/validations/shelley/utxow.rs @@ -48,7 +48,7 @@ fn get_cert_authors( TxCertificate::StakeDeregistration(addr) => { parse_cred(&addr.credential); } - // Delegation requries withness from delegator + // Delegation requries witness from delegator TxCertificate::StakeDelegation(deleg) => { parse_cred(&deleg.stake_address.credential); } @@ -58,7 +58,7 @@ fn get_cert_authors( vkey_hashes .extend(pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>()); } - // Pool retirement requires withness from pool cold key + // Pool retirement requires witness from pool cold key TxCertificate::PoolRetirement(retirement) => { vkey_hashes.insert(*retirement.operator); } @@ -258,7 +258,7 @@ pub fn validate_mir_insufficient_genesis_sigs( if genesis_sigs.len() < update_quorum as usize { return Err(Box::new( UTxOWValidationError::MIRInsufficientGenesisSigsUTXOW { - gensis_keys: genesis_sigs, + genesis_keys: genesis_sigs, quorum: update_quorum, }, )); diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index e66ab256..df3ad6b7 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -118,7 +118,7 @@ pub fn validate_mir_insufficient_genesis_sigs( if genesis_sigs.len() < update_quorum as usize { return Err(Box::new( UTxOWValidationError::MIRInsufficientGenesisSigsUTXOW { - gensis_keys: genesis_sigs, + genesis_keys: genesis_sigs, quorum: update_quorum, }, )); From 37ee907efafa29b82c3438d159a4c36b3bce1ece Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 18:56:43 +0100 Subject: [PATCH 12/13] refactor: remove redundant state in utxo_state --- modules/utxo_state/src/utxo_state.rs | 73 ++-------------------------- 1 file changed, 3 insertions(+), 70 deletions(-) diff --git a/modules/utxo_state/src/utxo_state.rs b/modules/utxo_state/src/utxo_state.rs index 3461d9a0..970c958f 100644 --- a/modules/utxo_state/src/utxo_state.rs +++ b/modules/utxo_state/src/utxo_state.rs @@ -2,11 +2,8 @@ //! Accepts UTXO events and derives the current ledger state in memory use acropolis_common::{ - caryatid::SubscriptionExt, messages::{CardanoMessage, Message, StateQuery, StateQueryResponse, StateTransitionMessage}, - protocol_params::ProtocolParams, queries::utxos::{UTxOStateQuery, UTxOStateQueryResponse, DEFAULT_UTXOS_QUERY_TOPIC}, - state_history::{StateHistory, StateHistoryStore}, }; use caryatid_sdk::{module, Context, Subscription}; @@ -42,14 +39,6 @@ mod validations; const DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC: (&str, &str) = ("utxo-deltas-subscribe-topic", "cardano.utxo.deltas"); -const DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC: (&str, &str) = ( - "bootstrapped-subscribe-topic", - "cardano.sequence.bootstrapped", -); -const DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = ( - "protocol-parameters-subscribe-topic", - "cardano.protocol.parameters", -); const DEFAULT_STORE: &str = "memory"; /// UTXO state module @@ -64,45 +53,12 @@ impl UTXOState { /// Main run function async fn run( state: Arc>, - pp_history: Arc>>, mut utxo_deltas_subscription: Box>, - mut bootstrapped_subscription: Box>, - mut protocol_parameters_subscription: Box>, ) -> Result<()> { - let (_, bootstrapped_message) = bootstrapped_subscription.read().await?; - let _genesis = match bootstrapped_message.as_ref() { - Message::Cardano((_, CardanoMessage::GenesisComplete(complete))) => { - complete.values.clone() - } - _ => panic!("Unexpected message in genesis completion topic: {bootstrapped_message:?}"), - }; - - // Consume initial protocol parameters - let _ = protocol_parameters_subscription.read().await?; - loop { - let mut _protocol_params = - pp_history.lock().await.get_or_init_with(ProtocolParams::default); - let Ok((_, message)) = utxo_deltas_subscription.read().await else { return Err(anyhow!("Failed to read UTxO deltas subscription error")); }; - let new_epoch = match message.as_ref() { - Message::Cardano((block_info, CardanoMessage::UTXODeltas(_))) => { - block_info.new_epoch && block_info.epoch > 0 - } - _ => false, - }; - - if new_epoch { - let (_, protocol_parameters_msg) = - protocol_parameters_subscription.read_ignoring_rollbacks().await?; - if let Message::Cardano((_, CardanoMessage::ProtocolParams(params))) = - protocol_parameters_msg.as_ref() - { - _protocol_params = params.params.clone(); - } - } match message.as_ref() { Message::Cardano((block, CardanoMessage::UTXODeltas(deltas_msg))) => { @@ -143,14 +99,6 @@ impl UTXOState { .get_string(DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC.0) .unwrap_or(DEFAULT_UTXO_DELTAS_SUBSCRIBE_TOPIC.1.to_string()); info!("Creating subscriber on '{utxo_deltas_subscribe_topic}'"); - let bootstrapped_subscribe_topic = config - .get_string(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.0) - .unwrap_or(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.1.to_string()); - info!("Creating bootstrapped subscriber on '{bootstrapped_subscribe_topic}'"); - let protocol_parameters_subscribe_topic = config - .get_string(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.0) - .unwrap_or(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.1.to_string()); - info!("Creating protocol parameters subscriber on '{protocol_parameters_subscribe_topic}'"); let utxos_query_topic = config .get_string(DEFAULT_UTXOS_QUERY_TOPIC.0) @@ -178,27 +126,12 @@ impl UTXOState { // Subscribers let utxo_deltas_subscription = context.subscribe(&utxo_deltas_subscribe_topic).await?; - let bootstrapped_subscription = context.subscribe(&bootstrapped_subscribe_topic).await?; - let protocol_parameters_subscription = - context.subscribe(&protocol_parameters_subscribe_topic).await?; - - // Prepare validation state history - let validation_state_history = Arc::new(Mutex::new(StateHistory::::new( - "utxo-state-validation", - StateHistoryStore::default_block_store(), - ))); let state_run = state.clone(); context.run(async move { - Self::run( - state_run, - validation_state_history, - utxo_deltas_subscription, - bootstrapped_subscription, - protocol_parameters_subscription, - ) - .await - .unwrap_or_else(|e| error!("Failed: {e}")); + Self::run(state_run, utxo_deltas_subscription) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); }); // Query handler From c9a7c5ec2ae30b9bcca301e5b6001accc5c5f0b4 Mon Sep 17 00:00:00 2001 From: Golddy Dev Date: Fri, 12 Dec 2025 19:04:09 +0100 Subject: [PATCH 13/13] refactor: remove redundant functions --- .../src/validations/shelley/utxow.rs | 179 +----------------- .../src/validations/shelley/utxow.rs | 45 +---- 2 files changed, 4 insertions(+), 220 deletions(-) diff --git a/modules/tx_unpacker/src/validations/shelley/utxow.rs b/modules/tx_unpacker/src/validations/shelley/utxow.rs index 95fb20c2..3b03cddb 100644 --- a/modules/tx_unpacker/src/validations/shelley/utxow.rs +++ b/modules/tx_unpacker/src/validations/shelley/utxow.rs @@ -6,9 +6,7 @@ use std::collections::HashSet; use crate::crypto::verify_ed25519_signature; use acropolis_common::{ - validation::UTxOWValidationError, AlonzoBabbageUpdateProposal, GenesisDelegates, KeyHash, - NativeScript, ScriptHash, ShelleyAddressPaymentPart, StakeCredential, TxCertificate, - TxCertificateWithPos, TxHash, UTXOValue, UTxOIdentifier, VKeyWitness, Withdrawal, + validation::UTxOWValidationError, GenesisDelegates, KeyHash, NativeScript, TxHash, VKeyWitness, }; use anyhow::Result; use pallas::ledger::primitives::alonzo; @@ -25,131 +23,6 @@ fn has_mir_certificate(mtx: &alonzo::MintedTx) -> bool { .unwrap_or(false) } -/// This function extracts required VKey Hashes -/// from TxCert (pallas type) -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/TxCert.hs#L583 -fn get_cert_authors( - cert_with_pos: &TxCertificateWithPos, -) -> (HashSet, HashSet) { - let mut vkey_hashes = HashSet::new(); - let mut script_hashes = HashSet::new(); - - let mut parse_cred = |cred: &StakeCredential| match cred { - StakeCredential::AddrKeyHash(vkey_hash) => { - vkey_hashes.insert(*vkey_hash); - } - StakeCredential::ScriptHash(script_hash) => { - script_hashes.insert(*script_hash); - } - }; - - match &cert_with_pos.cert { - // Deregistration requires witness from stake credential - TxCertificate::StakeDeregistration(addr) => { - parse_cred(&addr.credential); - } - // Delegation requries witness from delegator - TxCertificate::StakeDelegation(deleg) => { - parse_cred(&deleg.stake_address.credential); - } - // Pool registration requires witness from pool cold key and owners - TxCertificate::PoolRegistration(pool_reg) => { - vkey_hashes.insert(*pool_reg.operator); - vkey_hashes - .extend(pool_reg.pool_owners.iter().map(|o| o.get_hash()).collect::>()); - } - // Pool retirement requires witness from pool cold key - TxCertificate::PoolRetirement(retirement) => { - vkey_hashes.insert(*retirement.operator); - } - // Genesis delegation requires witness from genesis key - TxCertificate::GenesisKeyDelegation(gen_deleg) => { - vkey_hashes.insert(*gen_deleg.genesis_delegate_hash); - } - _ => {} - } - - (vkey_hashes, script_hashes) -} - -/// Get VKey Witnesses needed for transaction -/// Get Scripts needed for transaction -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L274 -/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L226 -/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/UTxO.hs#L103 -/// -/// VKey Witnesses needed -/// 1. UTxO authors: keys that own the UTxO being spent -/// 2. Certificate authors: keys authorizing certificates -/// 3. Pool owners: owners that must sign pool registration -/// 4. Withdrawal authors: keys authorizing reward withdrawals -/// 5. Governance authors: keys authorizing governance actions (e.g. protocol update) -/// -/// Script Witnesses needed -/// 1. Input scripts: scripts locking UTxO being spent -/// 2. Withdrawal scripts: scripts controlling reward accounts -/// 3. Certificate scripts: scripts in certificate credentials. -pub fn get_vkey_script_needed( - inputs: &[UTxOIdentifier], - certificates: &[TxCertificateWithPos], - withdrawals: &[Withdrawal], - alonzo_babbage_update_proposal: &Option, - lookup_utxo: F, -) -> (HashSet, HashSet) -where - F: Fn(&UTxOIdentifier) -> Result>, -{ - let mut vkey_hashes = HashSet::new(); - let mut script_hashes = HashSet::new(); - - // for each UTxO, extract the needed vkey and script hashes - for utxo in inputs.iter() { - if let Ok(Some(utxo)) = lookup_utxo(utxo) { - // NOTE: - // Need to check inputs from byron bootstrap addresses - // with bootstrap witnesses - if let Some(payment_part) = utxo.address.get_payment_part() { - match payment_part { - ShelleyAddressPaymentPart::PaymentKeyHash(payment_key_hash) => { - vkey_hashes.insert(payment_key_hash); - } - ShelleyAddressPaymentPart::ScriptHash(script_hash) => { - script_hashes.insert(script_hash); - } - } - } - } - } - - // for each certificate, get the required vkey and script hashes - for cert in certificates.iter() { - let (v, s) = get_cert_authors(cert); - vkey_hashes.extend(v); - script_hashes.extend(s); - } - - // for each withdrawal, get the required vkey and script hashes - for withdrawal in withdrawals.iter() { - match withdrawal.address.credential { - StakeCredential::AddrKeyHash(vkey_hash) => { - vkey_hashes.insert(vkey_hash); - } - StakeCredential::ScriptHash(script_hash) => { - script_hashes.insert(script_hash); - } - } - } - - // for each governance action, get the required vkey hashes - if let Some(update) = alonzo_babbage_update_proposal { - for (genesis_key, _) in update.proposals.iter() { - vkey_hashes.insert(*genesis_key); - } - } - - (vkey_hashes, script_hashes) -} - /// Validate Native Scripts from Transaction witnesses /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L373 pub fn validate_failed_native_scripts( @@ -171,40 +44,6 @@ pub fn validate_failed_native_scripts( Ok(()) } -/// Validate all needed scripts are provided in witnesses -/// No missing, no extra -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L386 -pub fn validate_missing_extra_scripts( - script_hashes_needed: &HashSet, - native_scripts: &[NativeScript], -) -> Result<(), Box> { - // check for missing & extra scripts - let mut scripts_used = - native_scripts.iter().map(|script| (false, script.compute_hash())).collect::>(); - for script_hash in script_hashes_needed.iter() { - if let Some((used, _)) = scripts_used.iter_mut().find(|(u, h)| !(*u) && script_hash.eq(h)) { - *used = true; - } else { - return Err(Box::new( - UTxOWValidationError::MissingScriptWitnessesUTxOW { - script_hash: *script_hash, - }, - )); - } - } - - for (used, script_hash) in scripts_used.iter() { - if !*used { - return Err(Box::new( - UTxOWValidationError::ExtraneousScriptWitnessesUTXOW { - script_hash: *script_hash, - }, - )); - } - } - Ok(()) -} - /// Validate that all vkey witnesses signatures /// are verified /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L401 @@ -223,22 +62,6 @@ pub fn validate_verified_wits( Ok(()) } -/// Validate that all required witnesses are provided -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L424 -pub fn validate_needed_witnesses( - vkey_hashes_needed: &HashSet, - vkey_hashes_provided: &HashSet, -) -> Result<(), Box> { - for vkey_hash in vkey_hashes_needed.iter() { - if !vkey_hashes_provided.contains(vkey_hash) { - return Err(Box::new(UTxOWValidationError::MissingVKeyWitnessesUTxOW { - key_hash: *vkey_hash, - })); - } - } - Ok(()) -} - /// Validate genesis keys signatures for MIR certificate /// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L463 pub fn validate_mir_insufficient_genesis_sigs( diff --git a/modules/utxo_state/src/validations/shelley/utxow.rs b/modules/utxo_state/src/validations/shelley/utxow.rs index df3ad6b7..268dd0ae 100644 --- a/modules/utxo_state/src/validations/shelley/utxow.rs +++ b/modules/utxo_state/src/validations/shelley/utxow.rs @@ -5,12 +5,12 @@ use std::collections::HashSet; use acropolis_common::{ - validation::UTxOWValidationError, GenesisDelegates, KeyHash, ScriptHash, - ShelleyAddressPaymentPart, TxCertificate, TxCertificateWithPos, UTXOValue, UTxOIdentifier, + validation::UTxOWValidationError, KeyHash, ScriptHash, ShelleyAddressPaymentPart, UTXOValue, + UTxOIdentifier, }; use anyhow::Result; -pub fn get_vkey_script_needed_from_inputs( +fn get_vkey_script_needed_from_inputs( inputs: &[UTxOIdentifier], vkey_hashes_needed: &mut HashSet, script_hashes_needed: &mut HashSet, @@ -88,45 +88,6 @@ pub fn validate_needed_witnesses( Ok(()) } -/// Validate genesis keys signatures for MIR certificate -/// Reference: https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/eras/shelley/impl/src/Cardano/Ledger/Shelley/Rules/Utxow.hs#L463 -pub fn validate_mir_insufficient_genesis_sigs( - certificates: &[TxCertificateWithPos], - vkey_hashes_provided: &HashSet, - genesis_delegs: &GenesisDelegates, - update_quorum: u32, -) -> Result<(), Box> { - let has_mir = certificates.iter().any(|cert_with_pos| { - matches!( - cert_with_pos.cert, - TxCertificate::MoveInstantaneousReward(_) - ) - }); - if !has_mir { - return Ok(()); - } - - let genesis_delegate_hashes = - genesis_delegs.as_ref().values().map(|delegate| delegate.delegate).collect::>(); - - // genSig := genDelegates ∩ witsKeyHashes - let genesis_sigs = - genesis_delegate_hashes.intersection(vkey_hashes_provided).copied().collect::>(); - - // Check: |genSig| ≥ Quorum - // If insufficient, report the signatures that were found (not the missing ones) - if genesis_sigs.len() < update_quorum as usize { - return Err(Box::new( - UTxOWValidationError::MIRInsufficientGenesisSigsUTXOW { - genesis_keys: genesis_sigs, - quorum: update_quorum, - }, - )); - } - - Ok(()) -} - pub fn validate( inputs: &[UTxOIdentifier], // Need to include vkey hashes and script hashes