Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7bad928
chore(1474): initialize work package branch
m2ux Jul 8, 2026
2464e1f
feat(events): T1 surface ledger event stream from apply sites (midnig…
m2ux Jul 8, 2026
2c3c23c
feat(events): T2 define LedgerEvent wire types and additive events fi…
m2ux Jul 8, 2026
b45bdd1
feat(events): T3 tagged-serialise ledger events into the return struc…
m2ux Jul 8, 2026
d1fe4f9
feat(events): T5 deposit one LedgerEvent per ledger event on pallet-m…
m2ux Jul 8, 2026
3926a27
feat(events): T6 deposit LedgerEvent on pallet-midnight-system (midni…
m2ux Jul 8, 2026
d9bb073
feat(events): T7 add bench_block_full_of_events pricing guardrail (mi…
m2ux Jul 8, 2026
cf1bcd4
docs(events): T8 document ledger-event surface and pricing (midnight-…
m2ux Jul 8, 2026
7ae9a5f
docs(events): T9 add runtime change-file entry (midnight-node#1474)
m2ux Jul 8, 2026
06f67ce
refactor(events): trim comment verbosity per manual review (midnight-…
m2ux Jul 8, 2026
216fdfd
test(events): ledger-crate coverage for ledger events (midnight-node#…
m2ux Jul 8, 2026
6d7457e
test(events): pallet-midnight coverage for ledger events (midnight-no…
m2ux Jul 8, 2026
ae03405
Merge branch 'main' into feat/1474-ledger-events-v2
m2ux Jul 13, 2026
84169dd
fix(events): allow clippy::type_complexity on ledger apply return typ…
m2ux Jul 13, 2026
61c9c34
chore: rebuild metadata (midnight-node#1474)
m2ux Jul 14, 2026
0ea9a90
fix(1474): remediate runtime-review findings on ledger events (#1474)
m2ux Jul 17, 2026
233f28d
Merge origin/main into feat/1474-ledger-events-v2
m2ux Jul 17, 2026
47b42cd
Merge origin/main into feat/1474-ledger-events-v2
m2ux Jul 17, 2026
ca06032
Merge branch 'main' of github.com:midnightntwrk/midnight-node into fe…
m2ux Jul 17, 2026
5a3941a
fix(1474): link ledger-events doc in README and sync openrpc.json to …
m2ux Jul 17, 2026
bc3821f
fix(1474): bound system-tx event weight to the governance-motion proo…
m2ux Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/release-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions changes/runtime/added/expose-ledger-events.md
Comment thread
m2ux marked this conversation as resolved.
Comment thread
m2ux marked this conversation as resolved.
Comment thread
m2ux marked this conversation as resolved.
Comment thread
m2ux marked this conversation as resolved.
Comment thread
m2ux marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions changes/toolkit/changed/runtime-version-2-2-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#toolkit #runtime
# Bump runtime spec version to 2.2.0

The merged runtime combines the 2.1.0 changes (pallet-babe, session-keys migration) with the new `System.Events` ABI (the `LedgerEvent` variants) and the versioned ledger host function, so it takes the next distinct runtime identity `2_002_000`.

PR: https://github.com/midnightntwrk/midnight-node/pull/1474
54 changes: 54 additions & 0 deletions docs/ledger-events.md
Original file line number Diff line number Diff line change
@@ -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<Event>` 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<u8>,
}

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::<EventDetails>`. 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.
2 changes: 1 addition & 1 deletion docs/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
132 changes: 132 additions & 0 deletions ledger/src/common/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ pub struct TransactionApplied {
pub claim_rewards: Vec<u128>,
}

/// Routing header for a ledger-emitted event
#[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
#[derive(Encode, Decode, DecodeWithMemTracking, TypeInfo, Clone, Eq, PartialEq, Debug)]
pub struct LedgerEvent {
pub source: LedgerEventSource,
pub content_tagged_bytes: Vec<u8>,
}

// 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<u8>,
Expand All @@ -62,6 +83,57 @@ pub struct SystemTransactionAppliedStateRoot {
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<u8>,
pub tx_hash: Hash,
pub all_applied: bool,
pub call_addresses: Vec<Vec<u8>>,
pub deploy_addresses: Vec<Vec<u8>>,
pub maintain_addresses: Vec<Vec<u8>>,
pub claim_rewards: Vec<u128>,
pub unshielded_utxos_created: Vec<UtxoInfo>,
pub unshielded_utxos_spent: Vec<UtxoInfo>,
pub events: Vec<LedgerEvent>,
}

#[derive(Encode, Decode, DecodeWithMemTracking)]
pub struct SystemTransactionAppliedStateRootWithEvents {
pub state_root: Vec<u8>,
pub tx_hash: Hash,
pub tx_type: String,
pub events: Vec<LedgerEvent>,
}

impl From<TransactionAppliedStateRootWithEvents> 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<SystemTransactionAppliedStateRootWithEvents> 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<u8>, entry_point: Vec<u8> },
Expand Down Expand Up @@ -276,3 +348,63 @@ 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);
}

// 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]);
}
}
Loading
Loading