From 7bad928a12e52d8203f921a75eaeac82a382eff2 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 09:57:03 +0100 Subject: [PATCH 01/17] chore(1474): initialize work package branch Scaffold commit to open the draft PR for midnight-node#1474. Implementation follows per the work package plan. Signed-off-by: Mike Clay From 2464e1f5f2b4c414d3762d87c57ced796142bbc1 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:17:03 +0100 Subject: [PATCH 02/17] feat(events): T1 surface ledger event stream from apply sites (midnight-node#1474) Stop discarding the per-transaction Vec> that the ledger already produces. Ledger::apply_verified_transaction now returns the event stream alongside its applied-stage classification, and Ledger::apply_system_tx keeps the event vec from its success tuple instead of dropping it. Because versions/common/ compiles once per ledger version via module parameterization, this single source edit lands in v7, v8 and v9. The two Bridge call sites destructure the vec but discard it for now; T3 consumes it. Failure semantics are unchanged: TransactionResult::Failure still returns LedgerApiError::Transaction(Invalid(..)) and emits no events. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- ledger/src/versions/common/api/ledger.rs | 27 ++++++++++++++++-------- ledger/src/versions/common/mod.rs | 10 +++++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/ledger/src/versions/common/api/ledger.rs b/ledger/src/versions/common/api/ledger.rs index 31cecc841..4a446698e 100644 --- a/ledger/src/versions/common/api/ledger.rs +++ b/ledger/src/versions/common/api/ledger.rs @@ -28,6 +28,7 @@ use ledger_storage_local::{ use helpers_local::{StorableSyntheticCost, compute_overall_fullness}; use midnight_serialize_local::{self as serialize, Tagged}; use mn_ledger_local::{ + events::Event, semantics::{TransactionContext, TransactionResult}, structure::{LedgerParameters, LedgerState, SignatureKind}, }; @@ -130,13 +131,17 @@ impl Ledger { /// /// This is used when a `VerifiedTransaction` has been cached from a prior /// validation step, avoiding redundant ZK proof verification. + /// + /// Returns the applied stage classification and the per-transaction + /// `Event` stream the ledger emitted. `TransactionResult::Failure` + /// produces no events and returns an error, matching the prior semantics. pub(crate) fn apply_verified_transaction>( sp: Sp, api: &Api, tx: &Transaction, verified_tx: &mn_ledger_local::structure::VerifiedTransaction, ctx: &TransactionContext, - ) -> Result<(Sp, AppliedStage), LedgerApiError> { + ) -> Result<(Sp, AppliedStage, Vec>), LedgerApiError> { let tx_cost = tx.0.cost(&sp.state.parameters, true) .map_err(|_| LedgerApiError::FeeCalculationError)?; @@ -154,15 +159,15 @@ impl Ledger { .alloc(Ledger { state: next_state, block_fullness: next_block_fullness.into() }); match result { - TransactionResult::Success(_) => Ok((new_sp, AppliedStage::AllApplied)), - TransactionResult::PartialSuccess(segments, _) => { + TransactionResult::Success(events) => Ok((new_sp, AppliedStage::AllApplied, events)), + TransactionResult::PartialSuccess(segments, events) => { log::warn!( target: LOG_TARGET, "Non guaranteed part of the transaction failed tx_hash = {:?}, segments = {:?}", tx.identifiers().map(|i| api.tagged_serialize(&i)).collect::>(), segments ); - Ok((new_sp, AppliedStage::PartialSuccess(segments.into_iter().collect()))) + Ok((new_sp, AppliedStage::PartialSuccess(segments.into_iter().collect()), events)) }, TransactionResult::Failure(reason) => { log::warn!(target: LOG_TARGET, "Error applying Transaction: {reason:?}"); @@ -225,14 +230,17 @@ impl Ledger { .alloc(Ledger { state: next_state, block_fullness: SyntheticCost::ZERO.into() }) } + /// Returns the new ledger snapshot together with the `Event` stream the + /// ledger emitted for the system transaction (`ParamChange`, + /// `DustInitialUtxo`, dust-generation events, etc.). pub(crate) fn apply_system_tx( sp: Sp, tx: &SystemTransaction, tblock: Timestamp, - ) -> Result, LedgerApiError> { + ) -> Result<(Sp, Vec>), LedgerApiError> { let tx_cost = tx.cost(&sp.state.parameters); let next_block_fullness = tx_cost + sp.block_fullness.clone().into(); - let (next_state, _) = sp.state.apply_system_tx(tx, tblock).map_err(|e| { + let (next_state, events) = sp.state.apply_system_tx(tx, tblock).map_err(|e| { log::error!(target: LOG_TARGET, "Error applying System Transaction: {e:?}"); LedgerApiError::Transaction(TransactionError::SystemTransaction(e.into())) })?; @@ -244,9 +252,10 @@ impl Ledger { &next_state.parameters.limits.block_limits, "apply_system_tx", )?; - Ok(default_storage::() + let new_sp = default_storage::() .arena - .alloc(Ledger { state: next_state, block_fullness: next_block_fullness.into() })) + .alloc(Ledger { state: next_state, block_fullness: next_block_fullness.into() }); + Ok((new_sp, events)) } pub(crate) fn get_unclaimed_amount(&self, beneficiary: UserAddress) -> Option<&u128> { @@ -327,7 +336,7 @@ mod tests { tx_ctx.block_context.tblock, ) .unwrap_or_else(|err| panic!("Transaction not well-formed: {err:?}")); - let (mut new_ledger_state, _applied_stage) = + let (mut new_ledger_state, _applied_stage, _events) = Ledger::::apply_verified_transaction( ledger.clone(), api, diff --git a/ledger/src/versions/common/mod.rs b/ledger/src/versions/common/mod.rs index 270e71c5d..874728db4 100644 --- a/ledger/src/versions/common/mod.rs +++ b/ledger/src/versions/common/mod.rs @@ -368,7 +368,10 @@ where "⏱️ Tx context ready (elapsed_ms={})", start_tx_processing_time.elapsed().as_millis() ); - let (mut new_ledger, applied_stage) = + // T3 consumes `_ledger_events` (tagged-serialise into `LedgerEvent` and + // attach to the return struct). For T1 it is discarded so the wire + // shape is unchanged. + let (mut new_ledger, applied_stage, _ledger_events) = Ledger::apply_verified_transaction(ledger, &api, &tx, &verified_tx, &tx_ctx)?; log::trace!( target: LOG_TARGET, @@ -543,7 +546,10 @@ where let tx_hash = tx.transaction_hash().0.0; let ledger = Self::get_ledger(&api, state_key)?; - let mut ledger = + // T3 consumes `_ledger_events` (tagged-serialise into `LedgerEvent` and + // attach to the return struct). For T1 it is discarded so the wire + // shape is unchanged. + let (mut ledger, _ledger_events) = Ledger::apply_system_tx(ledger, &tx, Timestamp::from_secs(block_context.tblock))?; let event = SystemTransactionAppliedStateRoot { From 2c3c23c5f0b7315bdc76ae0d8c1e0ba9ebe64d1e Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:37:01 +0100 Subject: [PATCH 03/17] feat(events): T2 define LedgerEvent wire types and additive events field (midnight-node#1474) Introduce the SCALE wire shape that carries ledger events across the host boundary, using the routing-header hybrid from the plan (section 3): - LedgerEventSource mirrors the ledger's EventSource (tag event-source[v1], stable across v7/v8/v9) field-for-field, so consumers can route on the (transaction_hash, logical_segment, physical_segment) triple. - LedgerEvent { source, content_tagged_bytes } keeps the variable/divergent EventDetails payload opaque as the ledger's own tagged bytes. The tag is self-describing, so version divergence (event-details[v9] vs [v14], the v9 ContractLog.logged_item change) needs no node-side branching. Both host-return structs gain an additive events: Vec field, appended last (design decision D1). Appending keeps the field decode-safe against historical replay: a decoder built with the field defaults it to an empty vec on bytes encoded before it existed, so no new #[version(N)] host function is required. Error plumbing: a new SerializationError::EventDetails variant and its SerializableError impl route tagged-serialise failures on EventDetails through the existing LedgerApiError::Serialization channel. The two Bridge constructors get empty-vec placeholders; T3 populates them. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- ledger/src/common/types.rs | 34 +++++++++++++++++++++++++++ ledger/src/versions/common/api/mod.rs | 9 +++++++ ledger/src/versions/common/mod.rs | 4 ++++ ledger/src/versions/common/types.rs | 5 ++++ 4 files changed, 52 insertions(+) diff --git a/ledger/src/common/types.rs b/ledger/src/common/types.rs index 8e24767b9..2982d263b 100644 --- a/ledger/src/common/types.rs +++ b/ledger/src/common/types.rs @@ -42,6 +42,33 @@ pub struct TransactionApplied { pub claim_rewards: Vec, } +/// Routing header for a ledger-emitted event. Mirrors the ledger's +/// `EventSource` (tag `event-source[v1]`, unchanged across v7/v8/v9) as a +/// SCALE-encodable struct so consumers can route on the source triple +/// `(transaction_hash, logical_segment, physical_segment)` without touching the +/// opaque event content. +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] +pub struct LedgerEventSource { + pub transaction_hash: Hash, + pub logical_segment: u16, + pub physical_segment: u16, +} + +/// One ledger event carried across the host boundary. +/// +/// `content_tagged_bytes` is the ledger crate's own `tagged_serialize` of the +/// event's `EventDetails`. The tag is a self-describing byte-prefix +/// (`event-details[v9]` for v7/v8, `event-details[v14]` for v9), so the payload +/// stays opaque to SCALE and a version-aware consumer deserialises it against +/// the matching ledger version. Keeping it opaque means a future ledger upgrade +/// that extends the upstream `#[non_exhaustive]` enum does not invalidate this +/// struct's SCALE metadata. +#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] +pub struct LedgerEvent { + pub source: LedgerEventSource, + pub content_tagged_bytes: Vec, +} + #[derive(Encode, Decode, DecodeWithMemTracking)] pub struct TransactionAppliedStateRoot { pub state_root: Vec, @@ -53,6 +80,10 @@ pub struct TransactionAppliedStateRoot { pub claim_rewards: Vec, pub unshielded_utxos_created: Vec, pub unshielded_utxos_spent: Vec, + // Appended last so the field is additive: a decoder built with this field + // defaults it to an empty vec on bytes encoded before it existed, keeping + // historical-block replay decode-safe. Populated in `Bridge::apply_transaction`. + pub events: Vec, } #[derive(Encode, Decode, DecodeWithMemTracking)] @@ -60,6 +91,9 @@ pub struct SystemTransactionAppliedStateRoot { pub state_root: Vec, pub tx_hash: Hash, pub tx_type: String, + // Appended last for the same additive-field reason as + // `TransactionAppliedStateRoot::events`. + pub events: Vec, } #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] diff --git a/ledger/src/versions/common/api/mod.rs b/ledger/src/versions/common/api/mod.rs index 9aa1cd95b..5c25a8403 100644 --- a/ledger/src/versions/common/api/mod.rs +++ b/ledger/src/versions/common/api/mod.rs @@ -133,6 +133,15 @@ impl DeserializableError for CNightGeneratesDustEvent { } } +// Ledger-emitted event payloads are tagged-serialised into the opaque bytes +// carried by `LedgerEvent`; failures surface through the existing +// `LedgerApiError::Serialization` channel. +impl SerializableError for mn_ledger_local::events::EventDetails { + fn error() -> SerializationError { + SerializationError::EventDetails + } +} + pub(crate) struct Api {} impl Api { diff --git a/ledger/src/versions/common/mod.rs b/ledger/src/versions/common/mod.rs index 874728db4..bd99f877d 100644 --- a/ledger/src/versions/common/mod.rs +++ b/ledger/src/versions/common/mod.rs @@ -443,6 +443,8 @@ where claim_rewards: vec![], unshielded_utxos_created: utxo_outputs, unshielded_utxos_spent: utxo_inputs, + // T3 populates this from the ledger event stream. + events: vec![], }; log::trace!( target: LOG_TARGET, @@ -556,6 +558,8 @@ where state_root: api.tagged_serialize(&ledger.as_typed_key())?, tx_hash, tx_type: tx_type.to_string(), + // T3 populates this from the ledger event stream. + events: vec![], }; // Only update state after no errors diff --git a/ledger/src/versions/common/types.rs b/ledger/src/versions/common/types.rs index c8db3d288..85fbfc349 100644 --- a/ledger/src/versions/common/types.rs +++ b/ledger/src/versions/common/types.rs @@ -233,6 +233,7 @@ pub enum SerializationError { CNightGeneratesDustEvent, SystemTransaction, ArenaHash, + EventDetails, } #[derive(Debug, Encode, Decode, DecodeWithMemTracking, Clone, TypeInfo, PalletError, PartialEq)] @@ -315,6 +316,9 @@ impl core::fmt::Display for LedgerApiError { SerializationError::ArenaHash => { write!(f, "Error serializing: ArenaHash") }, + SerializationError::EventDetails => { + write!(f, "Error serializing: EventDetails") + }, }, LedgerApiError::Transaction(error) => match error { Invalid(e) => write!(f, "Transaction Error: Invalid({e:?})"), @@ -389,6 +393,7 @@ impl From for u8 { SerializationError::CNightGeneratesDustEvent => 61, SerializationError::SystemTransaction => 62, SerializationError::ArenaHash => 63, + SerializationError::EventDetails => 64, }, // Reserved from [100-150) LedgerApiError::Transaction(error) => match error { From b45bdd1818b91d6f496417afb9b8cdd27c6a124b Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:39:46 +0100 Subject: [PATCH 04/17] feat(events): T3 tagged-serialise ledger events into the return structs (midnight-node#1474) Consume the event stream that T1 surfaced in Bridge::apply_transaction and Bridge::apply_system_transaction. A shared build_ledger_events helper mirrors each EventSource into the SCALE LedgerEventSource header and tagged-serialises the EventDetails payload into content_tagged_bytes, then populates the additive events field on the return struct. The helper is uniform across all three ledger versions: the tag is embedded in the serialised bytes, so the v9 divergence (event-details[v14], ContractLog.logged_item change) is absorbed opaquely with no per-version conversion code. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- ledger/src/versions/common/mod.rs | 46 +++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/ledger/src/versions/common/mod.rs b/ledger/src/versions/common/mod.rs index bd99f877d..031c1cfd7 100644 --- a/ledger/src/versions/common/mod.rs +++ b/ledger/src/versions/common/mod.rs @@ -66,6 +66,7 @@ use { midnight_primitives_ledger::{LedgerMetricsExt, LedgerStorageDb, LedgerStorageExt}, mn_ledger_local::{ dust::InitialNonce, + events::Event, structure::{ CNightGeneratesDustActionType, CNightGeneratesDustEvent, ClaimKind, ContractAction, MaintenanceUpdate, OutputInstructionUnshielded, ProofMarker, SignatureKind, @@ -80,9 +81,9 @@ use { }; use crate::common::types::{ - ContractCallsDetails, FallibleCoinsDetails, GasCost, GuaranteedCoinsDetails, Hash, Op, - SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, TransactionDetails, Tx, - WrappedHash, + ContractCallsDetails, FallibleCoinsDetails, GasCost, GuaranteedCoinsDetails, Hash, LedgerEvent, + LedgerEventSource, Op, SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, + TransactionDetails, Tx, WrappedHash, }; use super::BlockContext; @@ -368,10 +369,7 @@ where "⏱️ Tx context ready (elapsed_ms={})", start_tx_processing_time.elapsed().as_millis() ); - // T3 consumes `_ledger_events` (tagged-serialise into `LedgerEvent` and - // attach to the return struct). For T1 it is discarded so the wire - // shape is unchanged. - let (mut new_ledger, applied_stage, _ledger_events) = + let (mut new_ledger, applied_stage, ledger_events) = Ledger::apply_verified_transaction(ledger, &api, &tx, &verified_tx, &tx_ctx)?; log::trace!( target: LOG_TARGET, @@ -443,8 +441,7 @@ where claim_rewards: vec![], unshielded_utxos_created: utxo_outputs, unshielded_utxos_spent: utxo_inputs, - // T3 populates this from the ledger event stream. - events: vec![], + events: Self::build_ledger_events(&api, &ledger_events)?, }; log::trace!( target: LOG_TARGET, @@ -548,18 +545,14 @@ where let tx_hash = tx.transaction_hash().0.0; let ledger = Self::get_ledger(&api, state_key)?; - // T3 consumes `_ledger_events` (tagged-serialise into `LedgerEvent` and - // attach to the return struct). For T1 it is discarded so the wire - // shape is unchanged. - let (mut ledger, _ledger_events) = + let (mut ledger, ledger_events) = Ledger::apply_system_tx(ledger, &tx, Timestamp::from_secs(block_context.tblock))?; let event = SystemTransactionAppliedStateRoot { state_root: api.tagged_serialize(&ledger.as_typed_key())?, tx_hash, tx_type: tx_type.to_string(), - // T3 populates this from the ledger event stream. - events: vec![], + events: Self::build_ledger_events(&api, &ledger_events)?, }; // Only update state after no errors @@ -577,6 +570,29 @@ where Ok(event) } + /// Build the SCALE `Vec` envelope from the ledger's own + /// `Event` stream. `EventSource` is mirrored field-by-field; the + /// `EventDetails` payload is tagged-serialised so the wire shape stays + /// opaque to the codec and self-identifies its ledger version — the same + /// helper is uniform across v7/v8/v9 (the tag absorbs the divergence). + fn build_ledger_events( + api: &api::Api, + events: &[Event], + ) -> Result, LedgerApiError> { + events + .iter() + .map(|ev| { + let source = LedgerEventSource { + transaction_hash: ev.source.transaction_hash.0.0, + logical_segment: ev.source.logical_segment, + physical_segment: ev.source.physical_segment, + }; + let content_tagged_bytes = api.tagged_serialize(&ev.content)?; + Ok(LedgerEvent { source, content_tagged_bytes }) + }) + .collect() + } + pub fn validate_transaction( mut externalities: &mut dyn Externalities, state_key: &[u8], From d1fe4f91c573c31bc838051f811e263310583bad Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:48:52 +0100 Subject: [PATCH 05/17] feat(events): T5 deposit one LedgerEvent per ledger event on pallet-midnight (midnight-node#1474) Add Event::LedgerEvent(LedgerEvent) to the pallet-midnight event enum, appended last so the existing eight variants keep their positional indices. In send_mn_transaction, iterate the new result.events field and deposit one runtime event per ledger event (design decision D2), preserving per-event (pallet_index, variant_index) filtering for subxt / Polkadot.js consumers. The existing deposit sites are byte-identical. Emission is non-consensus narration and is not weighed (accept-unpriced, D3); the T7 benchmark is the guardrail for that decision. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- pallets/midnight/src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/pallets/midnight/src/lib.rs b/pallets/midnight/src/lib.rs index b1cefa549..3fb1bb6e3 100644 --- a/pallets/midnight/src/lib.rs +++ b/pallets/midnight/src/lib.rs @@ -52,7 +52,8 @@ pub mod pallet { use sidechain_domain::byte_string::BoundedString; use midnight_node_ledger::types::{ - self as LedgerTypes, GasCost, Tx as LedgerTx, UtxoInfo, active_ledger_bridge as LedgerApi, + self as LedgerTypes, GasCost, LedgerEvent, Tx as LedgerTx, UtxoInfo, + active_ledger_bridge as LedgerApi, active_version::{ BlockContext, DeserializationError, LedgerApiError, SerializationError, TransactionError, @@ -255,6 +256,11 @@ pub mod pallet { UnshieldedTokens(UnshieldedTokensDetails), /// Partial Success. TxPartialSuccess(TxAppliedDetails), + /// A ledger event emitted while applying the transaction. One deposited + /// per event the ledger produced; the payload's `content_tagged_bytes` + /// is the ledger's own tagged serialisation of the event details. + /// Appended last so the existing variant indices are unchanged. + LedgerEvent(LedgerEvent), } // Errors inform users that something went wrong. @@ -419,6 +425,13 @@ pub mod pallet { })); } + // One runtime event per ledger event, preserving per-event subxt / + // Polkadot.js filtering. Emission is non-consensus narration and is + // not weighed — see the accept-unpriced pricing note (T7/C1). + for ledger_event in result.events { + Self::deposit_event(Event::LedgerEvent(ledger_event)); + } + if result.all_applied { Self::deposit_event(Event::TxApplied(TxAppliedDetails { tx_hash })); } else { From 3926a27000311874161d005da7e16bb518b2d016 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:51:34 +0100 Subject: [PATCH 06/17] feat(events): T6 deposit LedgerEvent on pallet-midnight-system (midnight-node#1474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the symmetric system-tx change (design decision D4). Add a sibling Event::LedgerEvent(LedgerEvent) variant to pallet-midnight-system, appended last so the existing SystemTransactionApplied index is unchanged. Both system-tx apply paths — the send_mn_system_transaction dispatchable and the MidnightSystemTransactionExecutor::execute_system_transaction trait impl — now carry the new result.events field out of the mut_ledger_state closure and deposit one runtime event per ledger event beside the existing SystemTransactionApplied deposit. This surfaces ParamChange, DustInitialUtxo and dust-generation events that were previously invisible. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- pallets/midnight-system/src/lib.rs | 72 ++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/pallets/midnight-system/src/lib.rs b/pallets/midnight-system/src/lib.rs index 6d37834c3..f1af6d200 100644 --- a/pallets/midnight-system/src/lib.rs +++ b/pallets/midnight-system/src/lib.rs @@ -15,7 +15,7 @@ pub mod pallet { use alloc::vec::Vec; use midnight_node_ledger::types::{ - Hash, active_ledger_bridge as LedgerApi, + Hash, LedgerEvent, active_ledger_bridge as LedgerApi, active_version::{ DeserializationError, LedgerApiError, SerializationError, TransactionError, }, @@ -29,6 +29,12 @@ pub mod pallet { #[pallet::generate_deposit(pub (super) fn deposit_event)] pub enum Event { SystemTransactionApplied(SystemTransactionApplied), + /// A ledger event emitted while applying a system transaction (e.g. + /// `ParamChange`, `DustInitialUtxo`). One deposited per event the ledger + /// produced; `content_tagged_bytes` is the ledger's own tagged + /// serialisation of the event details. Appended last so the existing + /// variant index is unchanged. + LedgerEvent(LedgerEvent), } #[derive(Clone, Debug, PartialEq, Encode, Decode, DecodeWithMemTracking, TypeInfo)] @@ -130,16 +136,20 @@ pub mod pallet { let runtime_version = >::runtime_version().spec_version; let block_context = ::LedgerBlockContextProvider::get_block_context(); - let hash = ::LedgerStateProviderMut::mut_ledger_state(|state_key| { - let result = LedgerApi::apply_system_transaction( - &state_key, - &midnight_system_tx.clone(), - block_context, - runtime_version, - ) - .map_err(Error::::from)?; - Ok::<(Vec, Hash), Error>((result.state_root, result.tx_hash)) - })?; + let (hash, ledger_events) = + ::LedgerStateProviderMut::mut_ledger_state(|state_key| { + let result = LedgerApi::apply_system_transaction( + &state_key, + &midnight_system_tx.clone(), + block_context, + runtime_version, + ) + .map_err(Error::::from)?; + Ok::<(Vec, (Hash, Vec)), Error>(( + result.state_root, + (result.tx_hash, result.events), + )) + })?; Self::deposit_event(Event::::SystemTransactionApplied( super::SystemTransactionApplied { @@ -148,6 +158,12 @@ pub mod pallet { }, )); + // One runtime event per ledger event, mirroring pallet-midnight. See + // the accept-unpriced pricing note (T7/C1). + for ledger_event in ledger_events { + Self::deposit_event(Event::::LedgerEvent(ledger_event)); + } + Ok(()) } } @@ -157,24 +173,34 @@ pub mod pallet { serialized_system_transaction: Vec, ) -> Result { // Apply the System transaction - let hash = ::LedgerStateProviderMut::mut_ledger_state(|state_key| { - let runtime_version = >::runtime_version().spec_version; - let block_context = ::LedgerBlockContextProvider::get_block_context(); - let result = LedgerApi::apply_system_transaction( - &state_key, - &serialized_system_transaction.clone(), - block_context, - runtime_version, - ) - .map_err(Error::::from)?; - Ok::<(Vec, Hash), Error>((result.state_root, result.tx_hash)) - })?; + let (hash, ledger_events) = + ::LedgerStateProviderMut::mut_ledger_state(|state_key| { + let runtime_version = >::runtime_version().spec_version; + let block_context = + ::LedgerBlockContextProvider::get_block_context(); + let result = LedgerApi::apply_system_transaction( + &state_key, + &serialized_system_transaction.clone(), + block_context, + runtime_version, + ) + .map_err(Error::::from)?; + Ok::<(Vec, (Hash, Vec)), Error>(( + result.state_root, + (result.tx_hash, result.events), + )) + })?; // Emit System Transaction for the indexer Self::deposit_event(Event::::SystemTransactionApplied( super::SystemTransactionApplied { hash, serialized_system_transaction }, )); + // One runtime event per ledger event, mirroring pallet-midnight. + for ledger_event in ledger_events { + Self::deposit_event(Event::::LedgerEvent(ledger_event)); + } + Ok(hash) } } From d9bb073f32f6ceae040de2b9a4ae26d5d8b90483 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:54:03 +0100 Subject: [PATCH 07/17] feat(events): T7 add bench_block_full_of_events pricing guardrail (midnight-node#1474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the worst-case event-emission benchmark that discharges GO-gate condition C1 / acceptance metric M8. It fills a block with up to MAX_BENCH_EVENTS ledger events, each carrying a deploy-heavy ~4 KiB opaque payload, so the total event volume (~1 MiB) tracks the ledger's per-block bytes_churned ceiling — the code-grounded worst-case magnitude from the comprehension artifact (DD11). The benchmark measures the runtime-side deposit cost (the state-trie write into frame_system::Events) that the accept-unpriced decision (D3) deliberately leaves unweighed. The human runs it and compares the reported weight against BlockWeights headroom; this converts the accept-unpriced choice into a decision backed by measurement. Compilation and execution are deferred to the human runner (cargo bench / runtime-benchmarks harness). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- pallets/midnight/src/benchmarking.rs | 64 +++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/pallets/midnight/src/benchmarking.rs b/pallets/midnight/src/benchmarking.rs index ac64fdd60..35c8dcab2 100644 --- a/pallets/midnight/src/benchmarking.rs +++ b/pallets/midnight/src/benchmarking.rs @@ -17,14 +17,76 @@ use super::*; use alloc::vec::Vec; use frame_benchmarking::v2::*; use frame_system::RawOrigin; +use midnight_node_ledger::types::{LedgerEvent, LedgerEventSource}; + +/// Per-event payload size for the event-emission benchmark, in bytes. +/// +/// Sized to a deploy-heavy `ContractDeploy`/`ContractLog` event (~4 KiB of +/// tagged `content_tagged_bytes`). The upper bound on `MAX_BENCH_EVENTS` is +/// chosen so the total event volume approaches the ledger's per-block +/// `bytes_churned` ceiling (`INITIAL_LIMITS.block_limits.bytes_churned` = +/// 1_000_000), which is the worst-case magnitude derived in the comprehension +/// artifact (DD11): an event stream cannot exceed the state churn it narrates +/// by more than a constant framing overhead per event. +const BENCH_EVENT_PAYLOAD_BYTES: usize = 4 * 1024; + +/// Upper bound on the number of ledger events deposited in a single block for +/// the worst-case benchmark. `MAX_BENCH_EVENTS * BENCH_EVENT_PAYLOAD_BYTES` +/// (~1 MiB) tracks the `bytes_churned` block ceiling (see above). +const MAX_BENCH_EVENTS: u32 = 256; + +/// Build `count` synthetic `LedgerEvent`s with worst-case-sized opaque +/// payloads. The payload bytes are not a decodable event — this benchmark +/// measures the runtime-side deposit cost (state-trie write into +/// `frame_system::Events`), which is agnostic to the payload's internal shape. +fn generate_ledger_events(count: u32) -> Vec { + (0..count) + .map(|i| { + let mut transaction_hash = [0u8; 32]; + transaction_hash[0..4].copy_from_slice(&i.to_be_bytes()); + LedgerEvent { + source: LedgerEventSource { + transaction_hash, + logical_segment: 0, + physical_segment: (i % u16::MAX as u32) as u16, + }, + content_tagged_bytes: alloc::vec![0u8; BENCH_EVENT_PAYLOAD_BYTES], + } + }) + .collect() +} -// TODO #[benchmarks] mod benchmarks { use super::*; + #[benchmark] fn todo() { #[extrinsic_call] send_mn_transaction(RawOrigin::None, Vec::default()); } + + /// Worst-case event-emission guardrail (GO-gate condition C1 / metric M8). + /// + /// Fills a block with `n` worst-case-sized ledger events and measures the + /// cost of depositing them as runtime events. This validates the + /// accept-unpriced pricing decision (plan D3): `deposit_event` carries no + /// per-event weight term, so this benchmark bounds the state-write cost the + /// decision leaves unpriced. The human runs it and compares the reported + /// weight against `BlockWeights` headroom. + /// + /// Component `n`: number of ledger events in the block (0..MAX_BENCH_EVENTS). + #[benchmark] + fn bench_block_full_of_events(n: Linear<0, MAX_BENCH_EVENTS>) { + let events = generate_ledger_events(n); + + #[block] + { + for event in events { + Pallet::::deposit_event(Event::LedgerEvent(event)); + } + } + + assert_eq!(frame_system::Pallet::::event_count(), n); + } } From cf1bcd42514e55135874427e5962d2f414da8f81 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:58:42 +0100 Subject: [PATCH 08/17] docs(events): T8 document ledger-event surface and pricing (midnight-node#1474) Add docs/ledger-events.md covering the new runtime-event surface: - What is emitted: the per-event LedgerEvent variant on pallet-midnight and its sibling on pallet-midnight-system, and the routing-header-plus-opaque-bytes wire shape. - Indexer-author guidance: consume from frame_system::Events via subscribeStorage / getStorage, and decode content_tagged_bytes with the matching ledger version's tagged_deserialize (the tag self-identifies the version). - Contract-author guidance: (address, entry_point) is the contract-event namespace; no separate node-side topic field. - Pricing: the accept-unpriced decision, why it is safe, and the bench_block_full_of_events guardrail plus the per-event-weight-term fallback. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- docs/ledger-events.md | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/ledger-events.md diff --git a/docs/ledger-events.md b/docs/ledger-events.md new file mode 100644 index 000000000..00d037d05 --- /dev/null +++ b/docs/ledger-events.md @@ -0,0 +1,54 @@ +# Ledger events + +The node surfaces the per-transaction event stream that the [midnight-ledger](https://github.com/midnightntwrk/midnight-ledger) produces when it applies a transaction. Each ledger event is deposited as a Substrate runtime event, so consumers read it through standard Substrate tooling instead of re-applying transactions locally. + +## What is emitted + +When a transaction is applied, the ledger emits a `Vec` describing the effects (Zswap inputs and outputs, contract deploys, contract logs, parameter changes, dust events). The node forwards each of these as one runtime event: + +- `pallet_midnight::Event::LedgerEvent(LedgerEvent)` — for user transactions applied through `send_mn_transaction`. +- `pallet_midnight_system::Event::LedgerEvent(LedgerEvent)` — for system transactions (parameter changes, initial dust UTXOs, dust generation). + +Both variants are appended last on their event enums, so the existing event variants and their indices are unchanged. + +A `LedgerEvent` is: + +```rust +pub struct LedgerEvent { + pub source: LedgerEventSource, + pub content_tagged_bytes: Vec, +} + +pub struct LedgerEventSource { + pub transaction_hash: [u8; 32], + pub logical_segment: u16, + pub physical_segment: u16, +} +``` + +`source` is the routing header — a SCALE mirror of the ledger's `EventSource` (tag `event-source[v1]`, stable across every ledger version the node links). It lets a consumer route on `(transaction_hash, logical_segment, physical_segment)` without decoding the payload. + +`content_tagged_bytes` is the ledger's own tagged serialisation of the event's `EventDetails`. It is left opaque to the runtime: the runtime never needs to inspect event contents, and keeping the payload opaque means a future ledger upgrade that extends the event enum does not change this wire shape. + +Only committed transactions emit events. A failed transaction emits none, a partially-successful transaction emits events only for the segments that succeeded, and dry-run / mempool-validation paths never emit events. + +## Consuming events (indexer authors) + +Read the events for a block from `frame_system::Events`, either by subscribing to storage changes or by reading the storage value at a finalised block hash: + +- `state_subscribeStorage([System.Events])` for a live feed. +- `state_getStorage(System.Events, blockHash)` for a specific block. + +`frame_system::Events` is cleared at the start of each block, so it holds only the events for the block being queried. Runtime events live in the state trie, not in the gossiped block body — every full node re-derives them locally by executing the block's extrinsics. + +For each `LedgerEvent` record, decode `content_tagged_bytes` with the matching ledger version's `tagged_deserialize::`. The tag is a self-describing byte-prefix: it identifies both the type and the ledger version that produced it (`event-details[v9]` for the v7/v8-era ledgers, `event-details[v14]` for the v9-era ledger). A version-aware consumer dispatches on the prefix and selects the matching decoder; the node applies no version logic of its own. + +## Contract event namespacing (contract authors) + +A contract event arrives as an `EventDetails::ContractLog` inside `content_tagged_bytes`, carrying the emitting contract's `address` and the `entry_point` that produced it. Combined with the `source` routing header, the `(address, entry_point)` pair is the namespace for a contract-emitted event: two contracts that emit under the same `entry_point` remain distinguishable by their `address`. There is no separate node-side topic field — the address and entry point already travel inside the event payload. + +## Pricing + +Event emission is deliberately left unpriced: `deposit_event` carries no per-event weight term and there is no event cap. This matches the upstream FRAME convention for `frame_system::Events` (whitelisted, unbounded storage that is excluded from weight benchmarking). Event volume is transitively bounded by the ledger's per-block synthetic-cost limits (`bytes_churned`), which the transaction fee already pays for, and midnight-node is not a parachain, so there is no proof-of-validity inflation concern. + +The `bench_block_full_of_events` benchmark in `pallets/midnight/src/benchmarking.rs` is the guardrail for this decision: it fills a block with a worst-case event stream and measures the deposit cost against the block weight budget. If a future ledger version ever decouples event volume from state churn, the documented fallback is to add a per-event weight term to the transaction weight — a runtime-side change that does not affect the wire shape above. From 7ae9a5f97defbc4853d64427001c512f20f38c5f Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 12:59:38 +0100 Subject: [PATCH 09/17] docs(events): T9 add runtime change-file entry (midnight-node#1474) Add changes/runtime/added/expose-ledger-events.md per the in-tree change-file convention (component-tagged runtime addition), describing the new LedgerEvent runtime-event surface and pointing at docs/ledger-events.md. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- changes/runtime/added/expose-ledger-events.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 changes/runtime/added/expose-ledger-events.md diff --git a/changes/runtime/added/expose-ledger-events.md b/changes/runtime/added/expose-ledger-events.md new file mode 100644 index 000000000..16669e85f --- /dev/null +++ b/changes/runtime/added/expose-ledger-events.md @@ -0,0 +1,21 @@ +#runtime +# Expose ledger events as runtime events + +The runtime now surfaces the per-transaction event stream that the ledger +produces when it applies a transaction. Each ledger event is deposited as a +Substrate runtime event: `pallet_midnight::Event::LedgerEvent` for user +transactions and `pallet_midnight_system::Event::LedgerEvent` for system +transactions. Both variants are appended last, so the existing event variants +and their indices are unchanged. + +A `LedgerEvent` carries a SCALE routing header (`transaction_hash`, +`logical_segment`, `physical_segment`) plus the ledger's own tagged +serialisation of the event details as opaque bytes, so the wire shape is stable +across ledger versions. Consumers read events from `frame_system::Events` via +`state_subscribeStorage` / `state_getStorage` instead of re-applying +transactions. Emission is non-consensus and unpriced; see `docs/ledger-events.md`. + +Requires a metadata rebuild. + +PR: https://github.com/midnightntwrk/midnight-node/pull/1849 +Issue: https://github.com/midnightntwrk/midnight-node/issues/1474 From 06f67ce6e3f3b97f9a626b58bc1dd0e9f33bbd01 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 13:39:31 +0100 Subject: [PATCH 10/17] refactor(events): trim comment verbosity per manual review (midnight-node#1474) Trim agent-authored doc blocks and inline comments on the new ledger-event surface down to terse one-liners, matching the proportionality of the surrounding code. Comment-only; no logic change. Ref: midnightntwrk/midnight-node#1474 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- ledger/src/common/types.rs | 21 ++------------------- ledger/src/versions/common/mod.rs | 6 +----- pallets/midnight-system/src/lib.rs | 10 ++-------- pallets/midnight/src/benchmarking.rs | 17 ++++------------- pallets/midnight/src/lib.rs | 9 ++------- 5 files changed, 11 insertions(+), 52 deletions(-) diff --git a/ledger/src/common/types.rs b/ledger/src/common/types.rs index 2982d263b..13b542e4c 100644 --- a/ledger/src/common/types.rs +++ b/ledger/src/common/types.rs @@ -42,11 +42,7 @@ pub struct TransactionApplied { pub claim_rewards: Vec, } -/// Routing header for a ledger-emitted event. Mirrors the ledger's -/// `EventSource` (tag `event-source[v1]`, unchanged across v7/v8/v9) as a -/// SCALE-encodable struct so consumers can route on the source triple -/// `(transaction_hash, logical_segment, physical_segment)` without touching the -/// opaque event content. +/// Routing header for a ledger-emitted event #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] pub struct LedgerEventSource { pub transaction_hash: Hash, @@ -54,15 +50,7 @@ pub struct LedgerEventSource { pub physical_segment: u16, } -/// One ledger event carried across the host boundary. -/// -/// `content_tagged_bytes` is the ledger crate's own `tagged_serialize` of the -/// event's `EventDetails`. The tag is a self-describing byte-prefix -/// (`event-details[v9]` for v7/v8, `event-details[v14]` for v9), so the payload -/// stays opaque to SCALE and a version-aware consumer deserialises it against -/// the matching ledger version. Keeping it opaque means a future ledger upgrade -/// that extends the upstream `#[non_exhaustive]` enum does not invalidate this -/// struct's SCALE metadata. +/// One ledger event carried across the host boundary #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] pub struct LedgerEvent { pub source: LedgerEventSource, @@ -80,9 +68,6 @@ pub struct TransactionAppliedStateRoot { pub claim_rewards: Vec, pub unshielded_utxos_created: Vec, pub unshielded_utxos_spent: Vec, - // Appended last so the field is additive: a decoder built with this field - // defaults it to an empty vec on bytes encoded before it existed, keeping - // historical-block replay decode-safe. Populated in `Bridge::apply_transaction`. pub events: Vec, } @@ -91,8 +76,6 @@ pub struct SystemTransactionAppliedStateRoot { pub state_root: Vec, pub tx_hash: Hash, pub tx_type: String, - // Appended last for the same additive-field reason as - // `TransactionAppliedStateRoot::events`. pub events: Vec, } diff --git a/ledger/src/versions/common/mod.rs b/ledger/src/versions/common/mod.rs index 031c1cfd7..f4ecaec89 100644 --- a/ledger/src/versions/common/mod.rs +++ b/ledger/src/versions/common/mod.rs @@ -570,11 +570,7 @@ where Ok(event) } - /// Build the SCALE `Vec` envelope from the ledger's own - /// `Event` stream. `EventSource` is mirrored field-by-field; the - /// `EventDetails` payload is tagged-serialised so the wire shape stays - /// opaque to the codec and self-identifies its ledger version — the same - /// helper is uniform across v7/v8/v9 (the tag absorbs the divergence). + /// Build the SCALE `Vec` envelope from the ledger's own `Event` stream. fn build_ledger_events( api: &api::Api, events: &[Event], diff --git a/pallets/midnight-system/src/lib.rs b/pallets/midnight-system/src/lib.rs index f1af6d200..5ab69e77d 100644 --- a/pallets/midnight-system/src/lib.rs +++ b/pallets/midnight-system/src/lib.rs @@ -29,11 +29,6 @@ pub mod pallet { #[pallet::generate_deposit(pub (super) fn deposit_event)] pub enum Event { SystemTransactionApplied(SystemTransactionApplied), - /// A ledger event emitted while applying a system transaction (e.g. - /// `ParamChange`, `DustInitialUtxo`). One deposited per event the ledger - /// produced; `content_tagged_bytes` is the ledger's own tagged - /// serialisation of the event details. Appended last so the existing - /// variant index is unchanged. LedgerEvent(LedgerEvent), } @@ -158,8 +153,7 @@ pub mod pallet { }, )); - // One runtime event per ledger event, mirroring pallet-midnight. See - // the accept-unpriced pricing note (T7/C1). + // One runtime event per ledger event for ledger_event in ledger_events { Self::deposit_event(Event::::LedgerEvent(ledger_event)); } @@ -196,7 +190,7 @@ pub mod pallet { super::SystemTransactionApplied { hash, serialized_system_transaction }, )); - // One runtime event per ledger event, mirroring pallet-midnight. + // One runtime event per ledger event for ledger_event in ledger_events { Self::deposit_event(Event::::LedgerEvent(ledger_event)); } diff --git a/pallets/midnight/src/benchmarking.rs b/pallets/midnight/src/benchmarking.rs index 35c8dcab2..6c551eaa9 100644 --- a/pallets/midnight/src/benchmarking.rs +++ b/pallets/midnight/src/benchmarking.rs @@ -22,17 +22,12 @@ use midnight_node_ledger::types::{LedgerEvent, LedgerEventSource}; /// Per-event payload size for the event-emission benchmark, in bytes. /// /// Sized to a deploy-heavy `ContractDeploy`/`ContractLog` event (~4 KiB of -/// tagged `content_tagged_bytes`). The upper bound on `MAX_BENCH_EVENTS` is -/// chosen so the total event volume approaches the ledger's per-block -/// `bytes_churned` ceiling (`INITIAL_LIMITS.block_limits.bytes_churned` = -/// 1_000_000), which is the worst-case magnitude derived in the comprehension -/// artifact (DD11): an event stream cannot exceed the state churn it narrates -/// by more than a constant framing overhead per event. +/// tagged `content_tagged_bytes`). const BENCH_EVENT_PAYLOAD_BYTES: usize = 4 * 1024; /// Upper bound on the number of ledger events deposited in a single block for /// the worst-case benchmark. `MAX_BENCH_EVENTS * BENCH_EVENT_PAYLOAD_BYTES` -/// (~1 MiB) tracks the `bytes_churned` block ceiling (see above). +/// (~1 MiB) tracks the `bytes_churned` block ceiling. const MAX_BENCH_EVENTS: u32 = 256; /// Build `count` synthetic `LedgerEvent`s with worst-case-sized opaque @@ -66,14 +61,10 @@ mod benchmarks { send_mn_transaction(RawOrigin::None, Vec::default()); } - /// Worst-case event-emission guardrail (GO-gate condition C1 / metric M8). + /// Worst-case event-emission guardrail /// /// Fills a block with `n` worst-case-sized ledger events and measures the - /// cost of depositing them as runtime events. This validates the - /// accept-unpriced pricing decision (plan D3): `deposit_event` carries no - /// per-event weight term, so this benchmark bounds the state-write cost the - /// decision leaves unpriced. The human runs it and compares the reported - /// weight against `BlockWeights` headroom. + /// cost of depositing them as runtime events. /// /// Component `n`: number of ledger events in the block (0..MAX_BENCH_EVENTS). #[benchmark] diff --git a/pallets/midnight/src/lib.rs b/pallets/midnight/src/lib.rs index 3fb1bb6e3..dc7e970d0 100644 --- a/pallets/midnight/src/lib.rs +++ b/pallets/midnight/src/lib.rs @@ -256,10 +256,7 @@ pub mod pallet { UnshieldedTokens(UnshieldedTokensDetails), /// Partial Success. TxPartialSuccess(TxAppliedDetails), - /// A ledger event emitted while applying the transaction. One deposited - /// per event the ledger produced; the payload's `content_tagged_bytes` - /// is the ledger's own tagged serialisation of the event details. - /// Appended last so the existing variant indices are unchanged. + /// A ledger event emitted while applying the transaction LedgerEvent(LedgerEvent), } @@ -425,9 +422,7 @@ pub mod pallet { })); } - // One runtime event per ledger event, preserving per-event subxt / - // Polkadot.js filtering. Emission is non-consensus narration and is - // not weighed — see the accept-unpriced pricing note (T7/C1). + // One runtime event per ledger event for ledger_event in result.events { Self::deposit_event(Event::LedgerEvent(ledger_event)); } From 216fdfdef5f8fdba52934abeaae72219140fa7a9 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 15:07:27 +0100 Subject: [PATCH 11/17] test(events): ledger-crate coverage for ledger events (midnight-node#1474) Add PR1849-TC-01/TC-02 payload round-trip and TC-11/TC-14 wire-type SCALE round-trip. Strengthen the apply_verified_transaction test helper to return and assert the emitted events instead of discarding them. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- ledger/src/common/types.rs | 23 ++++++++++++++++++ ledger/src/versions/common/api/ledger.rs | 30 +++++++++++++++++++++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/ledger/src/common/types.rs b/ledger/src/common/types.rs index 13b542e4c..9f4907969 100644 --- a/ledger/src/common/types.rs +++ b/ledger/src/common/types.rs @@ -293,3 +293,26 @@ pub struct UtxoInfo { pub value: u128, pub output_no: u32, } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + // PR1849-TC-11/TC-14: the LedgerEvent wire types round-trip through SCALE, so + // the opaque payload carried across the host boundary decodes as encoded. + #[test] + fn ledger_event_scale_round_trips() { + let event = LedgerEvent { + source: LedgerEventSource { + transaction_hash: [7u8; 32], + logical_segment: 3, + physical_segment: 5, + }, + content_tagged_bytes: vec![1, 2, 3, 4], + }; + let bytes = event.encode(); + let decoded = LedgerEvent::decode(&mut &bytes[..]).expect("decode"); + assert_eq!(decoded, event); + } +} diff --git a/ledger/src/versions/common/api/ledger.rs b/ledger/src/versions/common/api/ledger.rs index 4a446698e..4e95ffb80 100644 --- a/ledger/src/versions/common/api/ledger.rs +++ b/ledger/src/versions/common/api/ledger.rs @@ -305,7 +305,7 @@ mod tests { undeployed::transactions::{CHECK_TX, CONTRACT_ADDR, DEPLOY_TX, MAINTENANCE_TX, STORE_TX}, }; use midnight_serialize_local::tagged_deserialize; - use mn_ledger_local::structure::LedgerState; + use mn_ledger_local::{events::EventDetails, structure::LedgerState}; fn prepare_ledger() -> Sp> { sp_tracing::try_init_simple(); @@ -324,7 +324,7 @@ mod tests { ledger: &mut Sp>, bytes: &[u8], block_context: &BlockContext, - ) { + ) -> Vec> { let tx = api .tagged_deserialize::>(bytes) .expect("failed to deserialize tx"); @@ -336,7 +336,7 @@ mod tests { tx_ctx.block_context.tblock, ) .unwrap_or_else(|err| panic!("Transaction not well-formed: {err:?}")); - let (mut new_ledger_state, _applied_stage, _events) = + let (mut new_ledger_state, _applied_stage, events) = Ledger::::apply_verified_transaction( ledger.clone(), api, @@ -351,6 +351,7 @@ mod tests { .expect("Post block update failed"); *ledger = new_ledger_state; + events } #[test] @@ -378,6 +379,29 @@ mod tests { assert_apply_transaction(&api, &mut ledger, &serialized_tx, &block_context.into()); } + /// PR1849-TC-01/TC-02: a successful apply emits events, and each event's + /// tagged-serialised `content` round-trips back to an equal `EventDetails`. + #[test] + fn should_emit_events_whose_payload_round_trips() { + if CRATE_NAME != crate::latest::CRATE_NAME { + println!("This test should only be run with ledger latest"); + return; + } + let api = Api::new(); + let mut ledger = prepare_ledger(); + let (serialized_tx, block_context) = extract_tx_with_context(DEPLOY_TX); + let events = + assert_apply_transaction(&api, &mut ledger, &serialized_tx, &block_context.into()); + + assert!(!events.is_empty(), "deploy should emit at least one event"); + for ev in &events { + let bytes = api.tagged_serialize(&ev.content).expect("serialize event content"); + let decoded: EventDetails = + tagged_deserialize(&bytes[..]).expect("round-trip decode"); + assert_eq!(decoded, ev.content); + } + } + #[test] fn should_get_contract_state() { if CRATE_NAME != crate::latest::CRATE_NAME { From 6d7457e8add142a7e75282f40a40e9b80adc675e Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Wed, 8 Jul 2026 15:07:47 +0100 Subject: [PATCH 12/17] test(events): pallet-midnight coverage for ledger events (midnight-node#1474) Add PR1849-TC-01 per-event deposit with tx-hash routing, TC-15 failed-tx emits nothing, TC-17 dry-run emits nothing, and TC-11 last-variant index check. Update the existing send_mn_transaction assertion for the new interleaved LedgerEvent records. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Mike Clay --- pallets/midnight/src/tests.rs | 89 +++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/pallets/midnight/src/tests.rs b/pallets/midnight/src/tests.rs index d9f4a287f..798006f90 100644 --- a/pallets/midnight/src/tests.rs +++ b/pallets/midnight/src/tests.rs @@ -20,8 +20,11 @@ use crate::{ use assert_matches::assert_matches; use frame_support::{assert_err, assert_ok, pallet_prelude::Weight, traits::OnFinalize}; use frame_system::RawOrigin; -use midnight_node_ledger::types::active_version::{ - BlockContext, DeserializationError, LedgerApiError, MalformedError, TransactionError, +use midnight_node_ledger::types::{ + LedgerEvent, LedgerEventSource, + active_version::{ + BlockContext, DeserializationError, LedgerApiError, MalformedError, TransactionError, + }, }; use midnight_node_res::{ networks::{MidnightNetwork, UndeployedNetwork}, @@ -29,6 +32,7 @@ use midnight_node_res::{ CHECK_TX, CONTRACT_ADDR, DEPLOY_TX, MAINTENANCE_TX, STORE_TX, ZSWAP_TX, }, }; +use parity_scale_codec::Encode; use sp_runtime::{ traits::ValidateUnsigned, transaction_validity::{InvalidTransaction, TransactionSource, TransactionValidityError}, @@ -64,13 +68,90 @@ fn test_send_mn_transaction() { assert_ok!(mock::Midnight::send_mn_transaction(RuntimeOrigin::none(), tx)); - // Check emitted events + // Check emitted events: ContractDeploy first, LedgerEvent(s) in between, TxApplied last. let events = mock::midnight_events(); assert_matches!(events[0], Event::ContractDeploy(_)); - assert_matches!(events[1], Event::TxApplied(_)); + assert_matches!(events.last().unwrap(), Event::TxApplied(_)); + assert!(events.iter().any(|e| matches!(e, Event::LedgerEvent(_)))); }) } +/// PR1849-TC-01: one `LedgerEvent` deposited per ledger event, all carrying the tx hash. +#[test] +fn test_send_mn_transaction_deposits_ledger_events() { + mock::new_test_ext().execute_with(|| { + let (tx, block_context) = + midnight_node_ledger_helpers::ledger_9::extract_tx_with_context(DEPLOY_TX); + init_ledger_state(block_context.into()); + + assert_ok!(mock::Midnight::send_mn_transaction(RuntimeOrigin::none(), tx)); + + let events = mock::midnight_events(); + let ledger_events: Vec<_> = events + .iter() + .filter_map(|e| if let Event::LedgerEvent(le) = e { Some(le) } else { None }) + .collect(); + assert!(!ledger_events.is_empty(), "deploy should emit at least one ledger event"); + + let tx_hash = events + .iter() + .find_map(|e| if let Event::TxApplied(d) = e { Some(d.tx_hash) } else { None }) + .expect("TxApplied present"); + for le in &ledger_events { + assert_eq!(le.source.transaction_hash, tx_hash); + } + }) +} + +/// PR1849-TC-15: a failed transaction deposits no `LedgerEvent`. +#[test] +fn test_failed_transaction_deposits_no_ledger_event() { + mock::new_test_ext().execute_with(|| { + // STORE_TX with no prior deploy fails on the guaranteed part. + let (tx, block_context) = + midnight_node_ledger_helpers::ledger_9::extract_tx_with_context(STORE_TX); + init_ledger_state(block_context.into()); + + let error: sp_runtime::DispatchError = Error::::Transaction( + TransactionError::Malformed(MalformedError::ContractNotPresent), + ) + .into(); + assert_err!(mock::Midnight::send_mn_transaction(RuntimeOrigin::none(), tx), error); + + assert!(mock::midnight_events().iter().all(|e| !matches!(e, Event::LedgerEvent(_)))); + }) +} + +/// PR1849-TC-17: dry-run validation (`pre_dispatch`) deposits no `LedgerEvent`. +#[test] +fn test_pre_dispatch_deposits_no_ledger_event() { + let (tx, block_context) = + midnight_node_ledger_helpers::ledger_9::extract_tx_with_context(DEPLOY_TX); + let call = MidnightCall::send_mn_transaction { midnight_tx: tx }; + mock::new_test_ext().execute_with(|| { + init_ledger_state(block_context.into()); + + assert_ok!(::pre_dispatch(&call)); + + assert!(mock::midnight_events().iter().all(|e| !matches!(e, Event::LedgerEvent(_)))); + }) +} + +/// PR1849-TC-11: `LedgerEvent` is appended as the last variant (index 8); +/// existing variant indices are unchanged. +#[test] +fn ledger_event_is_last_variant() { + let ledger_event = Event::LedgerEvent(LedgerEvent { + source: LedgerEventSource { + transaction_hash: [0u8; 32], + logical_segment: 0, + physical_segment: 0, + }, + content_tagged_bytes: alloc::vec::Vec::new(), + }); + assert_eq!(ledger_event.encode()[0], 8); +} + #[test] fn test_send_mn_transaction_malformed_tx() { mock::new_test_ext().execute_with(|| { From 84169dd5b2a5f0503961182f09fb534ba5d85522 Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Mon, 13 Jul 2026 18:15:50 +0100 Subject: [PATCH 13/17] fix(events): allow clippy::type_complexity on ledger apply return types (midnight-node#1474) The event-stream work added `Vec>` to the return tuples of `apply_verified_transaction` and `apply_system_tx`, pushing both past clippy's type-complexity threshold under `-D warnings` on the Rust 1.95 toolchain (merged from main). The tuple shapes are intentional and read clearly at the call sites, so annotate both methods with `#[allow(clippy::type_complexity)]`. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Mike Clay --- ledger/src/versions/common/api/ledger.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ledger/src/versions/common/api/ledger.rs b/ledger/src/versions/common/api/ledger.rs index 4e95ffb80..efce55977 100644 --- a/ledger/src/versions/common/api/ledger.rs +++ b/ledger/src/versions/common/api/ledger.rs @@ -135,6 +135,8 @@ impl Ledger { /// Returns the applied stage classification and the per-transaction /// `Event` stream the ledger emitted. `TransactionResult::Failure` /// produces no events and returns an error, matching the prior semantics. + // The return tuple carries the applied-stage classification alongside the ledger event stream. + #[allow(clippy::type_complexity)] pub(crate) fn apply_verified_transaction>( sp: Sp, api: &Api, @@ -233,6 +235,8 @@ impl Ledger { /// Returns the new ledger snapshot together with the `Event` stream the /// ledger emitted for the system transaction (`ParamChange`, /// `DustInitialUtxo`, dust-generation events, etc.). + // The return tuple carries the new ledger snapshot alongside the ledger event stream. + #[allow(clippy::type_complexity)] pub(crate) fn apply_system_tx( sp: Sp, tx: &SystemTransaction, From 61c9c3498db07e8b6457b9165d8bf0df29a2faad Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Tue, 14 Jul 2026 08:55:18 +0100 Subject: [PATCH 14/17] chore: rebuild metadata (midnight-node#1474) The ledger-event work deposits LedgerEvent from pallet-midnight and pallet-midnight-system, changing the runtime event surface, so the checked-in static metadata no longer matches the runtime (failing the metadata check and the toolkit's static codegen). Regenerate midnight_metadata.scale and the 2.0.0 snapshot via `subxt metadata -f bytes` (subxt 0.50.0) against the node image built for this branch. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Mike Clay --- metadata/static/midnight_metadata.scale | Bin 135125 -> 135509 bytes metadata/static/midnight_metadata_2.0.0.scale | Bin 135125 -> 135509 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/metadata/static/midnight_metadata.scale b/metadata/static/midnight_metadata.scale index 84dadc5869d003b19c03c79abd52ccf54534f833..46d48daa0956ae0996980adb16a1e5fef64333d5 100644 GIT binary patch delta 6628 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 Date: Fri, 17 Jul 2026 06:24:19 +0100 Subject: [PATCH 15/17] fix(1474): remediate runtime-review findings on ledger events (#1474) Address the eight findings from the runtime review of PR #1849, plus the metadata regeneration the review flagged as a release follow-up. - Emit the ledger transaction hash (not the state root) in the deposited system-transaction event on both the governance dispatchable and the internal executor, so downstream consumers can correlate the system event with its ledger events by hash. - Version the ledger host functions for the new return shape: the apply structs revert to their events-free (pre-PR, byte-identical) form and gain WithEvents siblings carrying the trailing events field on a new host-function version. WASM always calls the latest version its imports declare, so there is no negotiate-down: a new runtime on an old binary fails at WASM instantiation on the missing import rather than silently truncating a decode. The versioned split guards the reverse direction (an old events-free runtime on a new binary decodes the events-free shape); the pre-activation validator-binary gate is what keeps the mixed-version upgrade window safe by enforcing binaries-first rollout. - Re-anchor the event-volume benchmark to the real 50 MB bytesChurned ceiling instead of the earlier ~1 MiB bound. - Consume pending cross-chain accounting state only on system-transaction success: the subminimal-transfer accumulator is retained and the approved-hash entry re-inserted when execution fails, preserving single-use protection on success. - Add a pre-activation validator-binary compatibility gate to the runtime upgrade flow, refusing to authorize when the node's active runtime spec version is below the required minimum, with an explicit opt-out. - Apply the advertised include/exclude service filters during image rollout. - Bump the runtime spec_version to 2.1.0 for the changed System.Events ABI and the new versioned host function, and add a release-workflow guard that fails when runtime-ABI source changed since the last runtime tag but spec_version did not increase. - Add an event-volume weight term to both deposit paths: a per-event weight in cnight-observation and a pre-dispatch allowance refined post-dispatch in midnight-system. Per-event figures are conservative placeholders pending the benchmark run. - Regenerate the static runtime metadata for the new event variants. The per-event weight benchmark, the failure-branch bridge test, clippy, and the full test suite are run by the reviewer/author; compilation was verified via a full node release build. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Mike Clay --- .github/workflows/release-image.yml | 44 ++++++++ ledger/src/common/types.rs | 94 +++++++++++++++++- ledger/src/host_api/ledger_7.rs | 72 +++++++++++++- ledger/src/host_api/ledger_8.rs | 69 ++++++++++++- ledger/src/host_api/ledger_9.rs | 69 ++++++++++++- ledger/src/versions/common/mod.rs | 12 +-- .../src/commands/federatedRuntimeUpgrade.ts | 49 +++++++++ .../src/commands/imageUpgrade.ts | 24 ++++- local-environment/src/index.ts | 26 +++++ local-environment/src/lib/types.ts | 14 +++ metadata/static/midnight_metadata.scale | Bin 135509 -> 135509 bytes metadata/static/midnight_metadata_2.0.0.scale | Bin 135509 -> 135509 bytes pallets/c2m-bridge/src/lib.rs | 43 ++++++-- pallets/cnight-observation/src/lib.rs | 22 +++- pallets/cnight-observation/src/weights.rs | 17 ++++ pallets/midnight-system/src/lib.rs | 41 ++++++-- pallets/midnight/src/benchmarking.rs | 14 ++- runtime/src/lib.rs | 5 +- .../src/weights/pallet_cnight_observation.rs | 10 ++ 19 files changed, 590 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release-image.yml b/.github/workflows/release-image.yml index 3635cc7ee..3e21996d7 100644 --- a/.github/workflows/release-image.yml +++ b/.github/workflows/release-image.yml @@ -174,6 +174,50 @@ jobs: echo "TOOLKIT_TAG_RELEASE=$TOOLKIT_TAG_RELEASE" >> "$GITHUB_ENV" echo "RUNTIME_TAG_RELEASE=$RUNTIME_TAG_RELEASE" >> "$GITHUB_ENV" + - name: Guard against a changed runtime ABI at an unbumped spec_version + # Reject a release whose runtime-affecting source changed since the last + # runtime release but whose spec_version did not increase — that would ship + # a changed ABI under the base runtime identity (metadata/host-fn mismatch). + if: ${{ inputs.skip-runtime != 'true' }} + shell: bash + working-directory: ./.tmp-package + run: | + set -euo pipefail + + spec_to_int() { + echo "$1" | sed 's/_//g' | sed 's/^0*//' + } + + CURRENT_SPEC_RAW="$(grep -m 1 'spec_version:' runtime/src/lib.rs | awk '{print $2}' | tr -d ',')" + CURRENT_SPEC="$(spec_to_int "$CURRENT_SPEC_RAW")" + + # Fetch tags and history so we can resolve the previous runtime release. + git fetch --tags --quiet || true + git fetch --quiet --deepen=200 || true + + BASE_TAG="$(git tag --list 'runtime-*' --sort=-creatordate | head -n 1 || true)" + if [ -z "$BASE_TAG" ]; then + echo "No prior runtime-* tag found; skipping the changed-ABI guard (first runtime release)." + exit 0 + fi + echo "Comparing against base runtime tag: $BASE_TAG" + + BASE_SPEC_RAW="$(git show "$BASE_TAG:runtime/src/lib.rs" | grep -m 1 'spec_version:' | awk '{print $2}' | tr -d ',')" + BASE_SPEC="$(spec_to_int "$BASE_SPEC_RAW")" + + # Did any runtime-ABI-relevant source change since the base runtime tag? + if git diff --quiet "$BASE_TAG" -- runtime pallets ledger metadata; then + echo "No runtime/pallets/ledger/metadata changes since $BASE_TAG; ABI unchanged." + exit 0 + fi + + echo "Runtime-affecting source changed since $BASE_TAG (base spec_version $BASE_SPEC_RAW, current $CURRENT_SPEC_RAW)." + if [ "$CURRENT_SPEC" -le "$BASE_SPEC" ]; then + echo "::error::Runtime ABI changed since $BASE_TAG but spec_version did not increase ($CURRENT_SPEC_RAW <= $BASE_SPEC_RAW). Bump spec_version in runtime/src/lib.rs before releasing." + exit 1 + fi + echo "spec_version increased ($BASE_SPEC_RAW -> $CURRENT_SPEC_RAW); changed ABI carries a distinct runtime identity." + - name: Compute release configuration id: release-config env: diff --git a/ledger/src/common/types.rs b/ledger/src/common/types.rs index 9f4907969..0ea91e758 100644 --- a/ledger/src/common/types.rs +++ b/ledger/src/common/types.rs @@ -57,6 +57,12 @@ pub struct LedgerEvent { pub content_tagged_bytes: Vec, } +// The events-free structs are the return shape of the original (unversioned) host +// functions. Their SCALE encoding must stay byte-identical to a node binary built +// before the ledger-events change, so an old (events-free) runtime running on a new +// binary decodes them without a truncated-buffer failure. The events-carrying shape +// lives in the sibling `*WithEvents` structs, returned only by the bumped +// host-function version. #[derive(Encode, Decode, DecodeWithMemTracking)] pub struct TransactionAppliedStateRoot { pub state_root: Vec, @@ -68,7 +74,6 @@ pub struct TransactionAppliedStateRoot { pub claim_rewards: Vec, pub unshielded_utxos_created: Vec, pub unshielded_utxos_spent: Vec, - pub events: Vec, } #[derive(Encode, Decode, DecodeWithMemTracking)] @@ -76,9 +81,59 @@ pub struct SystemTransactionAppliedStateRoot { pub state_root: Vec, pub tx_hash: Hash, pub tx_type: String, +} + +// The events-carrying return shape. Field order mirrors the events-free struct with +// `events` appended last, so a decoder for the old shape reading this buffer stops +// before `events`; both are only ever decoded against their own host-function version. +#[derive(Encode, Decode, DecodeWithMemTracking)] +pub struct TransactionAppliedStateRootWithEvents { + pub state_root: Vec, + pub tx_hash: Hash, + pub all_applied: bool, + pub call_addresses: Vec>, + pub deploy_addresses: Vec>, + pub maintain_addresses: Vec>, + pub claim_rewards: Vec, + pub unshielded_utxos_created: Vec, + pub unshielded_utxos_spent: Vec, pub events: Vec, } +#[derive(Encode, Decode, DecodeWithMemTracking)] +pub struct SystemTransactionAppliedStateRootWithEvents { + pub state_root: Vec, + pub tx_hash: Hash, + pub tx_type: String, + pub events: Vec, +} + +impl From for TransactionAppliedStateRoot { + fn from(value: TransactionAppliedStateRootWithEvents) -> Self { + TransactionAppliedStateRoot { + state_root: value.state_root, + tx_hash: value.tx_hash, + all_applied: value.all_applied, + call_addresses: value.call_addresses, + deploy_addresses: value.deploy_addresses, + maintain_addresses: value.maintain_addresses, + claim_rewards: value.claim_rewards, + unshielded_utxos_created: value.unshielded_utxos_created, + unshielded_utxos_spent: value.unshielded_utxos_spent, + } + } +} + +impl From for SystemTransactionAppliedStateRoot { + fn from(value: SystemTransactionAppliedStateRootWithEvents) -> Self { + SystemTransactionAppliedStateRoot { + state_root: value.state_root, + tx_hash: value.tx_hash, + tx_type: value.tx_type, + } + } +} + #[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)] pub enum Op { Call { address: Vec, entry_point: Vec }, @@ -315,4 +370,41 @@ mod tests { let decoded = LedgerEvent::decode(&mut &bytes[..]).expect("decode"); assert_eq!(decoded, event); } + + // PR1849-TC-21: the events-free struct returned by the old host-function version + // encodes as the shared leading fields with no trailing `events` — i.e. it is a + // strict prefix of the events-carrying struct, which appends the `events` Vec last. + // This is what lets an old node binary's return decode under the new runtime. + #[test] + fn old_version_transaction_encoding_is_events_free_prefix() { + let with_empty_events = TransactionAppliedStateRootWithEvents { + state_root: vec![1, 2, 3], + tx_hash: [9u8; 32], + all_applied: true, + call_addresses: vec![vec![4, 5]], + deploy_addresses: vec![], + maintain_addresses: vec![], + claim_rewards: vec![42], + unshielded_utxos_created: vec![], + unshielded_utxos_spent: vec![], + events: vec![], + }; + let old_bytes = TransactionAppliedStateRoot { + state_root: vec![1, 2, 3], + tx_hash: [9u8; 32], + all_applied: true, + call_addresses: vec![vec![4, 5]], + deploy_addresses: vec![], + maintain_addresses: vec![], + claim_rewards: vec![42], + unshielded_utxos_created: vec![], + unshielded_utxos_spent: vec![], + } + .encode(); + let new_bytes = with_empty_events.encode(); + // The events-carrying encoding with an empty `events` is the old encoding plus + // one trailing compact-zero length byte for the empty Vec. + assert_eq!(&new_bytes[..old_bytes.len()], &old_bytes[..]); + assert_eq!(new_bytes[old_bytes.len()..], [0u8]); + } } diff --git a/ledger/src/host_api/ledger_7.rs b/ledger/src/host_api/ledger_7.rs index 706f41e98..29772bfcf 100644 --- a/ledger/src/host_api/ledger_7.rs +++ b/ledger/src/host_api/ledger_7.rs @@ -2,8 +2,9 @@ use crate::ledger_7::Bridge; use crate::{ common::types::{ - GasCost, Hash, SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, - TransactionDetails, Tx, + GasCost, Hash, SystemTransactionAppliedStateRoot, + SystemTransactionAppliedStateRootWithEvents, TransactionAppliedStateRoot, + TransactionAppliedStateRootWithEvents, TransactionDetails, Tx, }, ledger_7::{BlockContext, types::LedgerApiError}, }; @@ -123,6 +124,15 @@ pub trait LedgerBridge { /* * apply_transaction() + * + * The unversioned and `#[version(2)]` functions return the events-free + * `TransactionAppliedStateRoot`, byte-identical to a pre-ledger-events binary; + * `#[version(3)]` carries the events. WASM always calls the latest host-function + * version its imports declare, so this split guards the reverse direction: an old + * (events-free) runtime on a new binary decodes the events-free shape instead of + * silently truncating the events-carrying one. (A new runtime on an old binary + * fails at instantiation on the missing `#[version(3)]` import, not via a silent + * decode.) */ fn apply_transaction( &mut self, @@ -150,6 +160,7 @@ pub trait LedgerBridge { runtime_version, ) } + .map(Into::into) } #[version(2)] @@ -179,6 +190,36 @@ pub trait LedgerBridge { runtime_version, ) } + .map(Into::into) + } + + #[version(3)] + fn apply_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + runtime_version: u32, + ) -> AllocateAndReturnByCodec> { + if is_unified(*self) { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } else { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } } fn apply_system_transaction( @@ -203,6 +244,33 @@ pub trait LedgerBridge { block_context, ) } + .map(Into::into) + } + + #[version(2)] + fn apply_system_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + _runtime_version: u32, + ) -> AllocateAndReturnByCodec> + { + if is_unified(*self) { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } else { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } } /* diff --git a/ledger/src/host_api/ledger_8.rs b/ledger/src/host_api/ledger_8.rs index 13bf1a9c0..1fee3f7a9 100644 --- a/ledger/src/host_api/ledger_8.rs +++ b/ledger/src/host_api/ledger_8.rs @@ -2,7 +2,9 @@ use crate::ledger_8::Bridge; use crate::{ common::types::{ - GasCost, Hash, SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, Tx, + GasCost, Hash, SystemTransactionAppliedStateRoot, + SystemTransactionAppliedStateRootWithEvents, TransactionAppliedStateRoot, + TransactionAppliedStateRootWithEvents, Tx, }, ledger_8::{BlockContext, types::LedgerApiError}, }; @@ -104,6 +106,14 @@ pub trait Ledger8Bridge { /* * apply_transaction() + * + * The unversioned function returns the events-free `TransactionAppliedStateRoot`, + * byte-identical to a pre-ledger-events binary; `#[version(2)]` carries the events. + * WASM always calls the latest host-function version its imports declare, so this + * split guards the reverse direction: an old (events-free) runtime on a new binary + * decodes the events-free shape instead of silently truncating the events-carrying + * one. (A new runtime on an old binary fails at instantiation on the missing + * `#[version(2)]` import, not via a silent decode.) */ fn apply_transaction( &mut self, @@ -131,6 +141,36 @@ pub trait Ledger8Bridge { runtime_version, ) } + .map(Into::into) + } + + #[version(2)] + fn apply_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + runtime_version: u32, + ) -> AllocateAndReturnByCodec> { + if is_unified(*self) { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } else { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } } fn apply_system_transaction( @@ -155,6 +195,33 @@ pub trait Ledger8Bridge { block_context, ) } + .map(Into::into) + } + + #[version(2)] + fn apply_system_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + _runtime_version: u32, + ) -> AllocateAndReturnByCodec> + { + if is_unified(*self) { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } else { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } } /* diff --git a/ledger/src/host_api/ledger_9.rs b/ledger/src/host_api/ledger_9.rs index 35bf5617b..4070a94b7 100644 --- a/ledger/src/host_api/ledger_9.rs +++ b/ledger/src/host_api/ledger_9.rs @@ -2,7 +2,9 @@ use crate::ledger_9::Bridge; use crate::{ common::types::{ - GasCost, Hash, SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, Tx, + GasCost, Hash, SystemTransactionAppliedStateRoot, + SystemTransactionAppliedStateRootWithEvents, TransactionAppliedStateRoot, + TransactionAppliedStateRootWithEvents, Tx, }, ledger_9::{BlockContext, types::LedgerApiError}, }; @@ -104,6 +106,14 @@ pub trait Ledger9Bridge { /* * apply_transaction() + * + * The unversioned function returns the events-free `TransactionAppliedStateRoot`, + * byte-identical to a pre-ledger-events binary; `#[version(2)]` carries the events. + * WASM always calls the latest host-function version its imports declare, so this + * split guards the reverse direction: an old (events-free) runtime on a new binary + * decodes the events-free shape instead of silently truncating the events-carrying + * one. (A new runtime on an old binary fails at instantiation on the missing + * `#[version(2)]` import, not via a silent decode.) */ fn apply_transaction( &mut self, @@ -131,6 +141,36 @@ pub trait Ledger9Bridge { runtime_version, ) } + .map(Into::into) + } + + #[version(2)] + fn apply_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + runtime_version: u32, + ) -> AllocateAndReturnByCodec> { + if is_unified(*self) { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } else { + Bridge::::apply_transaction( + *self, + state_key, + tx, + block_context, + true, + runtime_version, + ) + } } fn apply_system_transaction( @@ -155,6 +195,33 @@ pub trait Ledger9Bridge { block_context, ) } + .map(Into::into) + } + + #[version(2)] + fn apply_system_transaction( + &mut self, + state_key: PassFatPointerAndRead<&[u8]>, + tx: PassFatPointerAndRead<&[u8]>, + block_context: PassFatPointerAndDecode, + _runtime_version: u32, + ) -> AllocateAndReturnByCodec> + { + if is_unified(*self) { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } else { + Bridge::::apply_system_transaction( + *self, + state_key, + tx, + block_context, + ) + } } /* diff --git a/ledger/src/versions/common/mod.rs b/ledger/src/versions/common/mod.rs index f4ecaec89..dbb5a02a2 100644 --- a/ledger/src/versions/common/mod.rs +++ b/ledger/src/versions/common/mod.rs @@ -82,8 +82,8 @@ use { use crate::common::types::{ ContractCallsDetails, FallibleCoinsDetails, GasCost, GuaranteedCoinsDetails, Hash, LedgerEvent, - LedgerEventSource, Op, SystemTransactionAppliedStateRoot, TransactionAppliedStateRoot, - TransactionDetails, Tx, WrappedHash, + LedgerEventSource, Op, SystemTransactionAppliedStateRootWithEvents, + TransactionAppliedStateRootWithEvents, TransactionDetails, Tx, WrappedHash, }; use super::BlockContext; @@ -319,7 +319,7 @@ where block_context: BlockContext, should_skip_failed_segments: bool, runtime_version: u32, - ) -> Result + ) -> Result where VerifiedTransaction: Send + Sync + 'static, { @@ -431,7 +431,7 @@ where start_tx_processing_time.elapsed().as_millis() ); - let mut event = TransactionAppliedStateRoot { + let mut event = TransactionAppliedStateRootWithEvents { state_root: api.tagged_serialize(&new_ledger.as_typed_key())?, tx_hash, all_applied, @@ -530,7 +530,7 @@ where state_key: &[u8], tx_serialized: &[u8], block_context: BlockContext, - ) -> Result { + ) -> Result { // Gather metrics for Prometheus let start_system_tx_processing_time = Instant::now(); let tx_size = tx_serialized.len(); @@ -548,7 +548,7 @@ where let (mut ledger, ledger_events) = Ledger::apply_system_tx(ledger, &tx, Timestamp::from_secs(block_context.tblock))?; - let event = SystemTransactionAppliedStateRoot { + let event = SystemTransactionAppliedStateRootWithEvents { state_root: api.tagged_serialize(&ledger.as_typed_key())?, tx_hash, tx_type: tx_type.to_string(), diff --git a/local-environment/src/commands/federatedRuntimeUpgrade.ts b/local-environment/src/commands/federatedRuntimeUpgrade.ts index 44c95ace8..3974a2804 100644 --- a/local-environment/src/commands/federatedRuntimeUpgrade.ts +++ b/local-environment/src/commands/federatedRuntimeUpgrade.ts @@ -54,6 +54,12 @@ export async function federatedRuntimeUpgrade( console.log(`Loaded runtime code hash: ${wasm.hash}`); + // Pre-activation gate: confirm the connected node runs a binary able to decode + // the new host-response structs (the versioned ledger host function) before the + // authorize_upgrade motion is submitted. A lagging binary would fail to decode + // the new runtime's host calls during a rolling upgrade. + await assertValidatorBinaryCompatible(api, opts); + const councilMembers = buildSigners(opts.councilUris, "Council"); const techCommitteeMembers = buildSigners( opts.techCommitteeUris, @@ -176,6 +182,49 @@ export async function federatedRuntimeUpgrade( } } +/** + * Verify the connected node's binary can decode the new host-response structs + * before the upgrade motion is submitted. The node's active-runtime spec_version + * is the operational proxy: a node still on a pre-bump runtime has not been rolled + * to a binary that provides the new versioned ledger host function. + * + * Refuses (throws) by default when the node is below the required spec_version; + * `allowLaggingBinary` downgrades this to a warning for local rehearsals. + */ +async function assertValidatorBinaryCompatible( + api: ApiPromise, + opts: FederatedRuntimeUpgradeOptions, +): Promise { + const nodeVersion = (await api.rpc.system.version()).toString(); + const activeSpecVersion = api.runtimeVersion.specVersion.toNumber(); + + console.log( + `Validator-binary compatibility probe: node version ${nodeVersion}, ` + + `active runtime spec_version ${activeSpecVersion}`, + ); + + if (opts.requiredNodeSpecVersion === undefined) { + console.warn( + "⚠️ No requiredNodeSpecVersion provided; skipping the validator-binary " + + "spec_version enforcement. Pass it to gate activation on binary capability.", + ); + return; + } + + if (activeSpecVersion < opts.requiredNodeSpecVersion) { + const message = + `Validator node reports active runtime spec_version ${activeSpecVersion}, ` + + `below the required ${opts.requiredNodeSpecVersion}: its binary may lack the ` + + `new ledger host-function version and would fail to decode the new runtime's ` + + `host calls. Roll the node binaries first, or pass --allow-lagging-binary to override.`; + if (opts.allowLaggingBinary) { + console.warn(`⚠️ ${message}`); + } else { + throw new Error(`❌ ${message}`); + } + } +} + function buildSigners(uris: string[], label: string): KeyringPair[] { if (!uris.length) { throw new Error(`${label} URIs are required`); diff --git a/local-environment/src/commands/imageUpgrade.ts b/local-environment/src/commands/imageUpgrade.ts index acf0070d4..5b4c51420 100644 --- a/local-environment/src/commands/imageUpgrade.ts +++ b/local-environment/src/commands/imageUpgrade.ts @@ -75,10 +75,17 @@ export async function imageUpgrade( ? [composeFile, overridePath] : [composeFile]; - const services = opts.services ?? (await listServices(composeFiles, env)); + const discovered = opts.services ?? (await listServices(composeFiles, env)); + // Apply the advertised --include/--exclude filters (forwarded here as + // includePattern/excludePattern) to the discovered service list before rollout. + const services = filterServices( + discovered, + opts.includePattern, + opts.excludePattern, + ); if (!services.length) { throw new Error( - "No services discovered to roll out. Provide ImageUpgradeOptions.services explicitly or check your compose file.", + "No services to roll out after applying include/exclude filters. Check ImageUpgradeOptions.services and the --include/--exclude patterns, or your compose file.", ); } @@ -163,6 +170,19 @@ function fileFlags(composeFiles: string[]): string[] { return composeFiles.flatMap((f) => ["-f", f]); } +function filterServices( + services: string[], + includePattern?: string, + excludePattern?: string, +): string[] { + const include = includePattern ? new RegExp(includePattern) : undefined; + const exclude = excludePattern ? new RegExp(excludePattern) : undefined; + return services.filter( + (svc) => + (!include || include.test(svc)) && (!exclude || !exclude.test(svc)), + ); +} + async function listServices( composeFiles: string[], env: Record, diff --git a/local-environment/src/index.ts b/local-environment/src/index.ts index f3b298500..dc05d7dca 100644 --- a/local-environment/src/index.ts +++ b/local-environment/src/index.ts @@ -52,6 +52,8 @@ interface FederatedRuntimeUpgradeCliOpts { skipRun?: boolean; fromSnapshot?: string; allowSameVersion?: boolean; + requiredNodeSpecVersion?: number; + allowLaggingBinary?: boolean; } interface FullUpgradeCliOpts { @@ -70,6 +72,8 @@ interface FullUpgradeCliOpts { technicalUris: string[]; executorUri: string; allowSameVersion?: boolean; + requiredNodeSpecVersion?: number; + allowLaggingBinary?: boolean; // shared profiles?: string[]; envFile?: string[]; @@ -242,6 +246,15 @@ program "--allow-same-version", "Use system.authorizeUpgradeWithoutChecks so the upgrade is accepted even if the candidate wasm shares spec_version with the running runtime. Local-rehearsal escape hatch; do not use against production-shaped networks.", ) + .option( + "--required-node-spec-version ", + "Minimum active runtime spec_version the connected node must report before the upgrade motion is submitted; gates on validator-binary capability for the new ledger host function.", + parseInt, + ) + .option( + "--allow-lagging-binary", + "Downgrade the validator-binary compatibility gate from refuse to warn. Local-rehearsal escape hatch; do not use against production-shaped networks.", + ) .description( "Execute a governance-approved runtime upgrade using the federated-authority pallet", ) @@ -278,6 +291,8 @@ program techCommitteeUris: techUris, motionExecutorUri: executorUri, allowSameVersion: cliOpts.allowSameVersion, + requiredNodeSpecVersion: cliOpts.requiredNodeSpecVersion, + allowLaggingBinary: cliOpts.allowLaggingBinary, }; await federatedRuntimeUpgrade(network, opts); @@ -337,6 +352,15 @@ program "--allow-same-version", "Use system.authorizeUpgradeWithoutChecks in phase 2 so the upgrade is accepted even if the candidate wasm shares spec_version with the running runtime. Local-rehearsal escape hatch; do not use against production-shaped networks.", ) + .option( + "--required-node-spec-version ", + "Minimum active runtime spec_version the connected node must report before the phase-2 upgrade motion is submitted; gates on validator-binary capability for the new ledger host function.", + parseInt, + ) + .option( + "--allow-lagging-binary", + "Downgrade the phase-2 validator-binary compatibility gate from refuse to warn. Local-rehearsal escape hatch; do not use against production-shaped networks.", + ) .description( "Run a two-phase upgrade rehearsal: roll the validator client image (phase 1), then submit a governance-approved runtime upgrade (phase 2)", ) @@ -378,6 +402,8 @@ program techCommitteeUris: techUris, motionExecutorUri: executorUri, allowSameVersion: cliOpts.allowSameVersion, + requiredNodeSpecVersion: cliOpts.requiredNodeSpecVersion, + allowLaggingBinary: cliOpts.allowLaggingBinary, // shared profiles, envFile: cliOpts.envFile, diff --git a/local-environment/src/lib/types.ts b/local-environment/src/lib/types.ts index 61dcab592..1e4812f69 100644 --- a/local-environment/src/lib/types.ts +++ b/local-environment/src/lib/types.ts @@ -68,6 +68,20 @@ export interface FederatedRuntimeUpgradeOptions * still catches real version-bump regressions. */ allowSameVersion?: boolean; + /** + * Minimum runtime spec_version the connected node's active runtime must report + * for its node binary to be considered capable of decoding the new host-response + * structs (the versioned ledger host function). When set, the pre-activation + * gate refuses to submit the upgrade motion if the node is below this value. + * Defaults to the candidate runtime's own spec_version when omitted. + */ + requiredNodeSpecVersion?: number; + /** + * Downgrade the validator-binary compatibility gate from refuse to warn. Intended + * for local rehearsals; production upgrades should leave this off so a lagging + * binary blocks activation rather than risking a mixed-binary decode failure. + */ + allowLaggingBinary?: boolean; } /** diff --git a/metadata/static/midnight_metadata.scale b/metadata/static/midnight_metadata.scale index 46d48daa0956ae0996980adb16a1e5fef64333d5..bdff13e5f78a913312d78166cc4d60eabd8915bc 100644 GIT binary patch delta 20 ccmcb*h~w%aj)pCaySkV%I=1iWVw~9q0A_#*hX4Qo delta 20 ccmcb*h~w%aj)pCaySkVfTDI@$Vw~9q0B0l#n*aa+ diff --git a/metadata/static/midnight_metadata_2.0.0.scale b/metadata/static/midnight_metadata_2.0.0.scale index 46d48daa0956ae0996980adb16a1e5fef64333d5..bdff13e5f78a913312d78166cc4d60eabd8915bc 100644 GIT binary patch delta 20 ccmcb*h~w%aj)pCaySkV%I=1iWVw~9q0A_#*hX4Qo delta 20 ccmcb*h~w%aj)pCaySkVfTDI@$Vw~9q0B0l#n*aa+ diff --git a/pallets/c2m-bridge/src/lib.rs b/pallets/c2m-bridge/src/lib.rs index 1b22d5095..e1ee11ee5 100644 --- a/pallets/c2m-bridge/src/lib.rs +++ b/pallets/c2m-bridge/src/lib.rs @@ -253,11 +253,19 @@ pub mod pallet { sp_crypto_hashing::blake2_256(&data) } + /// Serialize and execute a system transaction, depositing `make_event` on success. + /// + /// Returns `true` iff the system transaction executed successfully. Callers that + /// consumed pending accounting state before calling MUST branch on this: on + /// `false` the state was not committed on-chain, so it must be left intact for a + /// later retry rather than dropped. + #[must_use] fn execute_serialized_tx( result: Result, LedgerApiError>, make_event: F, description: &str, - ) where + ) -> bool + where F: FnOnce([u8; 32]) -> Event, { match result { @@ -270,17 +278,20 @@ pub mod pallet { log::debug!("Executed system transaction for {}", description); let event = make_event(tx_hash); Self::deposit_event(event); + true }, Err(e) => { log::error!( "Failed to execute system transaction for {}: {e:?}", description ); + false }, } }, Err(e) => { log::error!("Failed to serialize transaction for {}: {e:?}", description); + false }, } } @@ -293,7 +304,7 @@ pub mod pallet { let sum = sum.saturating_add(transfer.amount); let count = count.saturating_add(1); if sum > config.subminimal_transfers_flush_threshold { - Self::execute_serialized_tx( + let flushed = Self::execute_serialized_tx( LedgerApi::construct_unlock_to_treasury_system_tx(sum.into()), |midnight_tx_hash| Event::SubminimalFlushTransfer { amount: sum, @@ -302,7 +313,13 @@ pub mod pallet { }, &alloc::format!("subminimal transfers flush of total {}", sum), ); - SubminimalTransfers::::kill(); + if flushed { + SubminimalTransfers::::kill(); + } else { + // The flush did not commit on-chain — retain the accumulated + // balance so a later block retries rather than losing it. + SubminimalTransfers::::put(SubminimalTransfersState { count, sum }); + } } else { SubminimalTransfers::::put(SubminimalTransfersState { count, sum }); } @@ -321,7 +338,9 @@ pub mod pallet { } fn handle_invalid_transfer(mc_tx_hash: McTxHash, amount: u64) { - Self::execute_serialized_tx( + // Advisory: no replay-protected state is consumed here, so there is nothing + // to retain on failure — the treasury redirect is fire-and-forget. + let _ = Self::execute_serialized_tx( LedgerApi::construct_unlock_to_treasury_system_tx(amount.into()), |midnight_tx_hash| Event::InvalidTransfer { mc_tx_hash, amount, midnight_tx_hash }, &alloc::format!("'Invalid' transfer of {} from Cardano Tx: {}", amount, mc_tx_hash), @@ -329,7 +348,9 @@ pub mod pallet { } fn handle_reserve_transfer(mc_tx_hash: McTxHash, amount: u64) { - Self::execute_serialized_tx( + // Advisory: no replay-protected state is consumed here, so there is nothing + // to retain on failure — the reserve distribution is fire-and-forget. + let _ = Self::execute_serialized_tx( LedgerApi::construct_distribute_reserve_system_tx(amount.into()), |midnight_tx_hash| Event::ReserveTransfer { mc_tx_hash, amount, midnight_tx_hash }, &alloc::format!("'Reserve' transfer of {} from Cardano Tx: {}", amount, mc_tx_hash), @@ -342,7 +363,9 @@ pub mod pallet { match ApprovedMcTxHashes::::take(mc_tx_hash) { None => { // Not pre-approved by governance — redirect funds to the Treasury. - Self::execute_serialized_tx( + // Advisory: `take` consumed no approval on this arm (there was none), + // so there is nothing to restore on failure — fire-and-forget. + let _ = Self::execute_serialized_tx( LedgerApi::construct_unlock_to_treasury_system_tx(amount.into()), |midnight_tx_hash| Event::UnapprovedTransfer { mc_tx_hash, @@ -360,7 +383,7 @@ pub mod pallet { }, Some(_) => { let nonce = Self::generate_nonce(); - Self::execute_serialized_tx( + let executed = Self::execute_serialized_tx( LedgerApi::construct_distribute_night_cardano_bridge_system_tx( amount.into(), recipient.as_bytes(), @@ -379,6 +402,12 @@ pub mod pallet { mc_tx_hash ), ); + if !executed { + // Execution did not commit — restore the approval so the transfer + // can be retried on a later block. Single-use protection still + // holds: a succeeded call leaves the approval consumed. + ApprovedMcTxHashes::::insert(mc_tx_hash, ()); + } }, } } diff --git a/pallets/cnight-observation/src/lib.rs b/pallets/cnight-observation/src/lib.rs index 7e40033f8..213634f99 100644 --- a/pallets/cnight-observation/src/lib.rs +++ b/pallets/cnight-observation/src/lib.rs @@ -640,7 +640,18 @@ pub mod pallet { #[pallet::call] impl Pallet { #[pallet::call_index(0)] - #[pallet::weight((T::WeightInfo::process_tokens(CardanoTxCapacityPerBlock::::get().saturating_mul(UTXO_PER_TX_OVERESTIMATE)), DispatchClass::Mandatory))] + // Reserve the worst-case ledger-event deposit cost pre-dispatch: each observed + // UTXO deposits at most one ledger event, so the event count is bounded by the + // UTXO capacity. The post-dispatch `actual_weight` refines this to the real count. + #[pallet::weight(( + T::WeightInfo::process_tokens( + CardanoTxCapacityPerBlock::::get().saturating_mul(UTXO_PER_TX_OVERESTIMATE), + ) + .saturating_add(T::WeightInfo::ledger_event_deposit( + CardanoTxCapacityPerBlock::::get().saturating_mul(UTXO_PER_TX_OVERESTIMATE), + )), + DispatchClass::Mandatory, + ))] pub fn process_tokens( origin: OriginFor, utxos: Vec, @@ -726,6 +737,10 @@ pub mod pallet { NextCardanoPosition::::set(next_cardano_position.clone()); + // Count the ledger events the system transaction will deposit so the + // mandatory weight accounts for their `frame_system::Events` writes. + let ledger_event_count = events.len() as u32; + if !events.is_empty() { // Construct the Ledger system transaction // Note: this into-map should compile into a no-op @@ -750,7 +765,10 @@ pub mod pallet { } } Ok(PostDispatchInfo { - actual_weight: Some(T::WeightInfo::process_tokens(utxo_count)), + actual_weight: Some( + T::WeightInfo::process_tokens(utxo_count) + .saturating_add(T::WeightInfo::ledger_event_deposit(ledger_event_count)), + ), pays_fee: Pays::No, }) } diff --git a/pallets/cnight-observation/src/weights.rs b/pallets/cnight-observation/src/weights.rs index 0f8bd3c7d..44eb53f32 100644 --- a/pallets/cnight-observation/src/weights.rs +++ b/pallets/cnight-observation/src/weights.rs @@ -40,6 +40,10 @@ pub trait WeightInfo { fn process_tokens(n: u32) -> Weight; fn set_cnight_identifier() -> Weight; fn set_auth_token_asset_name() -> Weight; + /// Cost of depositing `n` ledger events into `frame_system::Events` from the + /// system transaction this pallet drives. Sized from the `pallet-midnight` + /// `bench_block_full_of_events` guardrail (per-event state-trie write). + fn ledger_event_deposit(n: u32) -> Weight; } /// Weights for `pallet_cnight_observation` using the Substrate node and recommended hardware. @@ -59,6 +63,16 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) } + /// Per-event ledger-event deposit cost (one state-trie write into + /// `frame_system::Events` per event). Placeholder until the `pallet-midnight` + /// `bench_block_full_of_events` guardrail is run — the measured per-event cost + /// replaces the conservative estimate below. + fn ledger_event_deposit(n: u32) -> Weight { + Weight::from_parts(5_000_000, 4096) + .saturating_mul(n.into()) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) + } + /// Single storage write to `CNightIdentifier`. Placeholder until benchmarked. fn set_cnight_identifier() -> Weight { Weight::from_parts(10_000_000, 0).saturating_add(T::DbWeight::get().writes(1)) @@ -75,6 +89,9 @@ impl WeightInfo for () { fn process_tokens(_n: u32) -> Weight { Weight::zero() } + fn ledger_event_deposit(_n: u32) -> Weight { + Weight::zero() + } fn set_cnight_identifier() -> Weight { Weight::zero() } diff --git a/pallets/midnight-system/src/lib.rs b/pallets/midnight-system/src/lib.rs index 5ab69e77d..6f3e3a65c 100644 --- a/pallets/midnight-system/src/lib.rs +++ b/pallets/midnight-system/src/lib.rs @@ -25,6 +25,17 @@ pub mod pallet { pub const EXTRA_WEIGHT_TX_SIZE: Weight = Weight::from_parts(20_000_000_000, 0); + /// Per-ledger-event deposit cost (one `frame_system::Events` state-trie write). + /// Sized from the `pallet-midnight` `bench_block_full_of_events` guardrail; + /// placeholder ref-time/proof-size pending the user's benchmark run. + pub const PER_LEDGER_EVENT_WEIGHT: Weight = Weight::from_parts(5_000_000, 4096); + + /// Worst-case ledger-event count a single system transaction can deposit, + /// anchored to the 50 MB `bytesChurned` ceiling at ~4 KiB per event. Bounds the + /// pre-dispatch weight declaration so the post-dispatch actual weight (which + /// reflects the real event count) never exceeds it. + pub const MAX_SYSTEM_TX_LEDGER_EVENTS: u64 = 50_000_000 / 4096; + #[pallet::event] #[pallet::generate_deposit(pub (super) fn deposit_event)] pub enum Event { @@ -117,11 +128,17 @@ pub mod pallet { #[pallet::call] impl Pallet { #[pallet::call_index(0)] - #[pallet::weight((ConfigurableSystemTxWeight::::get(), DispatchClass::Operational))] + // Pre-dispatch weight bounds the base system-tx weight plus the worst-case + // ledger-event deposit allowance; the actual event count refines it below. + #[pallet::weight(( + ConfigurableSystemTxWeight::::get() + .saturating_add(PER_LEDGER_EVENT_WEIGHT.saturating_mul(MAX_SYSTEM_TX_LEDGER_EVENTS)), + DispatchClass::Operational, + ))] pub fn send_mn_system_transaction( origin: OriginFor, midnight_system_tx: Vec, - ) -> DispatchResult { + ) -> DispatchResultWithPostInfo { ensure_root(origin)?; ensure!( LedgerApi::is_governance_allowed_system_tx(&midnight_system_tx), @@ -131,7 +148,7 @@ pub mod pallet { let runtime_version = >::runtime_version().spec_version; let block_context = ::LedgerBlockContextProvider::get_block_context(); - let (hash, ledger_events) = + let (tx_hash, ledger_events) = ::LedgerStateProviderMut::mut_ledger_state(|state_key| { let result = LedgerApi::apply_system_transaction( &state_key, @@ -140,6 +157,8 @@ pub mod pallet { runtime_version, ) .map_err(Error::::from)?; + // First tuple element is the new state key written back by + // `mut_ledger_state`; the tx hash and events ride out as the payload. Ok::<(Vec, (Hash, Vec)), Error>(( result.state_root, (result.tx_hash, result.events), @@ -148,17 +167,21 @@ pub mod pallet { Self::deposit_event(Event::::SystemTransactionApplied( super::SystemTransactionApplied { - hash, + hash: tx_hash, serialized_system_transaction: midnight_system_tx, }, )); + let ledger_event_count = ledger_events.len() as u64; // One runtime event per ledger event for ledger_event in ledger_events { Self::deposit_event(Event::::LedgerEvent(ledger_event)); } - Ok(()) + // Refine to the base weight plus the events actually deposited. + let actual_weight = ConfigurableSystemTxWeight::::get() + .saturating_add(PER_LEDGER_EVENT_WEIGHT.saturating_mul(ledger_event_count)); + Ok(Some(actual_weight).into()) } } @@ -167,7 +190,7 @@ pub mod pallet { serialized_system_transaction: Vec, ) -> Result { // Apply the System transaction - let (hash, ledger_events) = + let (tx_hash, ledger_events) = ::LedgerStateProviderMut::mut_ledger_state(|state_key| { let runtime_version = >::runtime_version().spec_version; let block_context = @@ -179,6 +202,8 @@ pub mod pallet { runtime_version, ) .map_err(Error::::from)?; + // First tuple element is the new state key written back by + // `mut_ledger_state`; the tx hash and events ride out as the payload. Ok::<(Vec, (Hash, Vec)), Error>(( result.state_root, (result.tx_hash, result.events), @@ -187,7 +212,7 @@ pub mod pallet { // Emit System Transaction for the indexer Self::deposit_event(Event::::SystemTransactionApplied( - super::SystemTransactionApplied { hash, serialized_system_transaction }, + super::SystemTransactionApplied { hash: tx_hash, serialized_system_transaction }, )); // One runtime event per ledger event @@ -195,7 +220,7 @@ pub mod pallet { Self::deposit_event(Event::::LedgerEvent(ledger_event)); } - Ok(hash) + Ok(tx_hash) } } } diff --git a/pallets/midnight/src/benchmarking.rs b/pallets/midnight/src/benchmarking.rs index 6c551eaa9..f24ab1615 100644 --- a/pallets/midnight/src/benchmarking.rs +++ b/pallets/midnight/src/benchmarking.rs @@ -25,10 +25,16 @@ use midnight_node_ledger::types::{LedgerEvent, LedgerEventSource}; /// tagged `content_tagged_bytes`). const BENCH_EVENT_PAYLOAD_BYTES: usize = 4 * 1024; -/// Upper bound on the number of ledger events deposited in a single block for -/// the worst-case benchmark. `MAX_BENCH_EVENTS * BENCH_EVENT_PAYLOAD_BYTES` -/// (~1 MiB) tracks the `bytes_churned` block ceiling. -const MAX_BENCH_EVENTS: u32 = 256; +/// The `bytesChurned` per-block ceiling from the shipped network configs +/// (`res/*/ledger-parameters-config.json`). Event volume is transitively bounded +/// by this limit, so the worst-case block the guardrail fills must approximate it. +const BYTES_CHURNED_CEILING: usize = 50_000_000; + +/// Upper bound on the number of ledger events deposited in a single block for the +/// worst-case benchmark, anchored to the real `bytesChurned` ceiling: +/// `MAX_BENCH_EVENTS * BENCH_EVENT_PAYLOAD_BYTES` ≈ 50 MB, the effective ceiling a +/// block can carry — not the ~1 MiB the guardrail previously exercised. +const MAX_BENCH_EVENTS: u32 = (BYTES_CHURNED_CEILING / BENCH_EVENT_PAYLOAD_BYTES) as u32; /// Build `count` synthetic `LedgerEvent`s with worst-case-sized opaque /// payloads. The payload bytes are not a decodable event — this benchmark diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5542e4c17..b4dd1f20f 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -280,7 +280,10 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // The version of the runtime specification. A full node will not attempt to use its native // runtime in substitute for the on-chain Wasm runtime unless all of `spec_name`, // `spec_version`, and `authoring_version` are the same between Wasm and native. - spec_version: 002_000_000, + // Bumped from 002_000_000: this runtime changes the `System.Events` ABI (the new + // `LedgerEvent` variants) and adds a versioned ledger host function, so it must + // carry a distinct runtime identity rather than shipping under the base 2.0.0 tag. + spec_version: 002_001_000, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 4, diff --git a/runtime/src/weights/pallet_cnight_observation.rs b/runtime/src/weights/pallet_cnight_observation.rs index 3f12c0545..cd1d472ac 100644 --- a/runtime/src/weights/pallet_cnight_observation.rs +++ b/runtime/src/weights/pallet_cnight_observation.rs @@ -96,4 +96,14 @@ impl pallet_cnight_observation::weights::WeightInfo for Weight::from_parts(10_000_000, 0) .saturating_add(T::DbWeight::get().writes(1)) } + /// Per-event ledger-event deposit cost (one state-trie write into + /// `frame_system::Events` per event). Provisional figure mirroring the + /// pallet's `SubstrateWeight` placeholder; pending the user's + /// `bench_block_full_of_events` run (DI-PRICE-HOLD), which regenerates + /// this file with the measured per-event cost. + fn ledger_event_deposit(n: u32, ) -> Weight { + Weight::from_parts(5_000_000, 4096) + .saturating_mul(n.into()) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) + } } From 5a3941a1ead1c8669e2e3dc86cad6741df16002d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Fri, 17 Jul 2026 13:33:06 +0100 Subject: [PATCH 16/17] fix(1474): link ledger-events doc in README and sync openrpc.json to 2.2.0 Satisfies the docs-link test (docs/ledger-events.md was unlinked in the root README) and the static openrpc sync test (docs/openrpc.json version lagged the 2.2.0 runtime bump; no RPC method surface change). Signed-off-by: Mike Clay --- README.md | 1 + docs/openrpc.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 38b76d303..9ad7d7356 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ that we are still in the process of being release. As such: - [Development Workflow](docs/development-workflow.md) - Best practices for cargo vs earthly, debugging, and common tasks - [OpenRPC API Specification](docs/openrpc.md) - Machine-readable API schema via `rpc.discover` +- [Ledger Events](docs/ledger-events.md) - Ledger-emitted events exposed as runtime events for indexers and other consumers - [Configuration Guide](docs/configuration-guide.md) - Comprehensive configuration guide for SREs - [Rust Installation](docs/rust-setup.md) - Setup instructions and toolchain information - [Chain Specifications](docs/chain_specs.md) - Working with different networks diff --git a/docs/openrpc.json b/docs/openrpc.json index d758dabdb..2d1b1f533 100644 --- a/docs/openrpc.json +++ b/docs/openrpc.json @@ -2,7 +2,7 @@ "openrpc": "1.4.0", "info": { "title": "Midnight Node JSON-RPC API", - "version": "2.1.0", + "version": "2.2.0", "description": "JSON-RPC API for the Midnight privacy blockchain node. Custom methods provide access to the privacy ledger, governance parameters, and peer management. Standard Substrate methods are also listed." }, "methods": [ From bc3821f1be43e5d9117c782b1340e1bf9466071d Mon Sep 17 00:00:00 2001 From: Mike Clay Date: Fri, 17 Jul 2026 13:39:30 +0100 Subject: [PATCH 17/17] fix(1474): bound system-tx event weight to the governance-motion proof envelope RB8's pre-dispatch event allowance for send_mn_system_transaction was anchored to the per-block 50 MB churn ceiling (~12k events), giving the call a ~50 MB proof-size weight that no governance motion can bound. The update-ledger-parameters E2E failed with FederatedAuthority::MotionWeightBoundTooLow. Cap the per-call worst case at 200 events so the pre-dispatch weight fits the ~1 MB motion proof envelope; the post-dispatch actual weight still charges the real event count. Signed-off-by: Mike Clay --- pallets/midnight-system/src/lib.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pallets/midnight-system/src/lib.rs b/pallets/midnight-system/src/lib.rs index 6f3e3a65c..4ab168e4e 100644 --- a/pallets/midnight-system/src/lib.rs +++ b/pallets/midnight-system/src/lib.rs @@ -30,11 +30,12 @@ pub mod pallet { /// placeholder ref-time/proof-size pending the user's benchmark run. pub const PER_LEDGER_EVENT_WEIGHT: Weight = Weight::from_parts(5_000_000, 4096); - /// Worst-case ledger-event count a single system transaction can deposit, - /// anchored to the 50 MB `bytesChurned` ceiling at ~4 KiB per event. Bounds the - /// pre-dispatch weight declaration so the post-dispatch actual weight (which - /// reflects the real event count) never exceeds it. - pub const MAX_SYSTEM_TX_LEDGER_EVENTS: u64 = 50_000_000 / 4096; + /// Worst-case ledger events a single system transaction can deposit, sized to + /// fit the governance-motion proof envelope (~1 MB / ~4 KiB per event). Bounds + /// the pre-dispatch weight so a governed system tx stays within a motion's + /// weight bound; the post-dispatch actual weight reflects the real count. + /// Distinct from the per-block 50 MB benchmark ceiling in pallet-midnight. + pub const MAX_SYSTEM_TX_LEDGER_EVENTS: u64 = 200; #[pallet::event] #[pallet::generate_deposit(pub (super) fn deposit_event)]