From 604db65f239c3c1900098f5dc281b3975e903d27 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Mon, 20 Apr 2026 22:22:38 +0530 Subject: [PATCH 01/13] feat: add pallet-block-forwarder for offchain HTTP indexer forwarding Offchain worker that forwards table lifecycle and data events (SchemaUpdated, TablesCreatedWithCommitments, TableDropped, QuorumReached) to an external HTTP indexer service via protobuf-over-HTTP. Architecture: - Producer (on_finalize): extracts events during block execution, writes block-indexed entries to the offchain DB. Runs during both live blocks and historical sync, enabling full backfill for new nodes. - Consumer (offchain_worker): drains the offchain DB queue in deposit order, POSTs to the HTTP server, checkpoints, deletes consumed entries. - Server checkpoint is the sole source of truth (no local cursor). - No new host functions; raw DDL and postcard bytes shipped as-is. - Processes up to 100 blocks per OCW invocation for catch-up. - Dynamic indexing pallet detection: IndexingPallet (PalletInfoAccess) resolves the pallet index from construct_runtime!; QuorumReachedVariantIndex is a Get the runtime provides (currently ConstU8<1>). Includes the proto definition, an HTTP+protobuf client, the producer/consumer pipeline, an axum-based mock server for local testing, helper scripts, and seven unit/integration tests covering the full pipeline. --- Cargo.lock | 85 ++++ Cargo.toml | 7 +- pallets/block_forwarder/Cargo.toml | 57 +++ pallets/block_forwarder/build.rs | 8 + .../block_forwarder/mock-server/Cargo.toml | 17 + pallets/block_forwarder/mock-server/build.rs | 6 + .../block_forwarder/mock-server/src/main.rs | 175 ++++++++ pallets/block_forwarder/proto/indexer.proto | 42 ++ .../block_forwarder/scripts/configure-ocw.sh | 74 ++++ .../block_forwarder/scripts/run-local-demo.sh | 54 +++ pallets/block_forwarder/src/http_client.rs | 152 +++++++ pallets/block_forwarder/src/lib.rs | 388 ++++++++++++++++++ pallets/block_forwarder/src/mock.rs | 76 ++++ pallets/block_forwarder/src/offchain_index.rs | 83 ++++ pallets/block_forwarder/src/tests.rs | 302 ++++++++++++++ runtime/Cargo.toml | 2 + runtime/src/lib.rs | 11 + 17 files changed, 1537 insertions(+), 2 deletions(-) create mode 100644 pallets/block_forwarder/Cargo.toml create mode 100644 pallets/block_forwarder/build.rs create mode 100644 pallets/block_forwarder/mock-server/Cargo.toml create mode 100644 pallets/block_forwarder/mock-server/build.rs create mode 100644 pallets/block_forwarder/mock-server/src/main.rs create mode 100644 pallets/block_forwarder/proto/indexer.proto create mode 100755 pallets/block_forwarder/scripts/configure-ocw.sh create mode 100755 pallets/block_forwarder/scripts/run-local-demo.sh create mode 100644 pallets/block_forwarder/src/http_client.rs create mode 100644 pallets/block_forwarder/src/lib.rs create mode 100644 pallets/block_forwarder/src/mock.rs create mode 100644 pallets/block_forwarder/src/offchain_index.rs create mode 100644 pallets/block_forwarder/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index fba46947..b02499c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9570,6 +9570,20 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "mock-indexer-server" +version = "0.1.0" +dependencies = [ + "axum", + "prost 0.13.5", + "prost-build 0.13.5", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "mockall" version = "0.11.4" @@ -10732,6 +10746,24 @@ dependencies = [ "sp-state-machine", ] +[[package]] +name = "pallet-block-forwarder" +version = "0.1.0" +dependencies = [ + "log", + "pallet-commitments", + "pallet-permissions", + "pallet-tables", + "parity-scale-codec", + "parking_lot 0.12.3", + "polkadot-sdk 0.9.0", + "proof-of-sql-commitment-map", + "prost 0.13.5", + "prost-build 0.13.5", + "scale-info", + "sxt-core", +] + [[package]] name = "pallet-bounties" version = "37.0.2" @@ -15502,6 +15534,16 @@ dependencies = [ "prost-derive 0.12.6", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost-build" version = "0.11.9" @@ -15545,6 +15587,26 @@ dependencies = [ "tempfile", ] +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools 0.14.0", + "log", + "multimap 0.10.0", + "once_cell", + "petgraph", + "prettyplease 0.2.30", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.106", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.11.9" @@ -15571,6 +15633,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "prost-types" version = "0.11.9" @@ -15589,6 +15664,15 @@ dependencies = [ "prost 0.12.6", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "protobuf" version = "2.28.0" @@ -21140,6 +21224,7 @@ dependencies = [ "eth-ecdsa", "native-api", "pallet-attestation", + "pallet-block-forwarder", "pallet-commitments", "pallet-indexing", "pallet-keystore", diff --git a/Cargo.toml b/Cargo.toml index 515cc286..14c02ffc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ members = [ "pallets/rewards", "pallets/keystore", "pallets/smartcontracts", - "event-forwarder", + "event-forwarder", "chain-utils", "pallets/system-contracts", "canaries", @@ -39,6 +39,8 @@ members = [ "proof-of-sql/commitment-column-mapping", "eth-ecdsa", "arrow-ipc-no-std", + "pallets/block_forwarder", + "pallets/block_forwarder/mock-server", ] exclude = [ "utoipa", @@ -47,7 +49,7 @@ resolver = "2" [workspace.dependencies] polkadot-sdk = { version = "0.9.0", default-features = false } -sc-cli = { version = "0.47.0", default-features = false } +sc-cli = { version = "0.47.0", default-features = false } sc-client-db = { version = "0.44.1", default-features = false } # Required for the runtime_interface macro in our native modules @@ -79,6 +81,7 @@ pallet-smartcontracts = { path = "pallets/smartcontracts", default-features = fa sxt-core = { path = "sxt-core", default-features = false } pallet-tables = { path = "pallets/tables", default-features = false } pallet-indexing = { path = "pallets/indexing", default-features = false } +pallet-block-forwarder = { path = "pallets/block_forwarder", default-features = false } native = { path = "native", default-features = false } native-api = { path = "native-api", default-features = false } num-bigint = { version = "0.4", default-features = false } diff --git a/pallets/block_forwarder/Cargo.toml b/pallets/block_forwarder/Cargo.toml new file mode 100644 index 00000000..641c8612 --- /dev/null +++ b/pallets/block_forwarder/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "pallet-block-forwarder" +description = "Offchain worker that forwards table lifecycle and data events to an external HTTP indexer service." +version = "0.1.0" +license = "Unlicense" +authors.workspace = true +homepage.workspace = true +repository.workspace = true +edition.workspace = true +publish = false + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { features = ["derive"], workspace = true } +scale-info = { features = ["derive"], workspace = true } + +polkadot-sdk = { workspace = true, features = [ + "frame-support", "frame-system", "sp-core", "sp-io", "sp-runtime", +]} + +sxt-core.workspace = true +pallet-tables.workspace = true +log = { workspace = true, default-features = false } +prost = { version = "0.13", default-features = false, features = ["prost-derive"] } + +[build-dependencies] +prost-build = "0.13" + +[dev-dependencies] +polkadot-sdk = { workspace = true, features = [ + "sp-core", "sp-io", "sp-runtime", "pallet-balances", +]} +pallet-permissions = { workspace = true, features = ["std"] } +pallet-commitments = { workspace = true, features = ["std"] } +pallet-tables = { workspace = true, features = ["std"] } +proof-of-sql-commitment-map.workspace = true +parking_lot = "0.12" + +[features] +default = ["std"] +std = [ + "polkadot-sdk/std", + "codec/std", + "scale-info/std", + "sxt-core/std", + "pallet-tables/std", + "log/std", + "prost/std", +] +try-runtime = [ + "polkadot-sdk/try-runtime", +] + +[lints] +workspace = true diff --git a/pallets/block_forwarder/build.rs b/pallets/block_forwarder/build.rs new file mode 100644 index 00000000..cc6a6b0e --- /dev/null +++ b/pallets/block_forwarder/build.rs @@ -0,0 +1,8 @@ +//! Compiles `proto/indexer.proto` into Rust types via `prost_build`. + +fn main() { + prost_build::Config::new() + .compile_protos(&["proto/indexer.proto"], &["proto"]) + .expect("failed to compile proto/indexer.proto"); + println!("cargo:rerun-if-changed=proto/indexer.proto"); +} diff --git a/pallets/block_forwarder/mock-server/Cargo.toml b/pallets/block_forwarder/mock-server/Cargo.toml new file mode 100644 index 00000000..49a17961 --- /dev/null +++ b/pallets/block_forwarder/mock-server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mock-indexer-server" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +axum = { version = "0.8", features = ["tokio"] } +prost = "0.13" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[build-dependencies] +prost-build = "0.13" diff --git a/pallets/block_forwarder/mock-server/build.rs b/pallets/block_forwarder/mock-server/build.rs new file mode 100644 index 00000000..6e0f8fd8 --- /dev/null +++ b/pallets/block_forwarder/mock-server/build.rs @@ -0,0 +1,6 @@ +fn main() { + prost_build::Config::new() + .compile_protos(&["../proto/indexer.proto"], &["../proto"]) + .expect("failed to compile indexer.proto"); + println!("cargo:rerun-if-changed=../proto/indexer.proto"); +} diff --git a/pallets/block_forwarder/mock-server/src/main.rs b/pallets/block_forwarder/mock-server/src/main.rs new file mode 100644 index 00000000..feb06b32 --- /dev/null +++ b/pallets/block_forwarder/mock-server/src/main.rs @@ -0,0 +1,175 @@ +//! Mock HTTP server that mirrors the indexer gRPC service's proto, but over +//! HTTP + protobuf. Used for local testing of the block forwarder pallet. +//! +//! Endpoints: +//! POST /v1/create_table — body: CreateTableRequest (protobuf) +//! POST /v1/drop_table — body: DropTableRequest (protobuf) +//! POST /v1/put_batches — body: PutBatchesRequest (protobuf) +//! POST /v1/checkpoint — body: CheckpointRequest (protobuf) +//! POST /v1/get_last_checkpoint — empty body, response: GetLastCheckpointResponse (protobuf) +//! +//! State is held in memory (not persisted). Checkpoint rule: the first +//! checkpoint accepts any sequence number; subsequent checkpoints must be +//! `last_checkpoint + 1`. Re-posting the current checkpoint is accepted +//! as an idempotent no-op. + +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::post; +use axum::Router; +use prost::Message; + +mod proto { + include!(concat!(env!("OUT_DIR"), "/io.spaceandtime.indexer.rs")); +} + +/// In-memory server state. +#[derive(Debug, Default)] +struct ServerState { + /// Last checkpointed sequence number. + last_checkpoint: Option, + /// Known tables (by fully qualified name). + tables: std::collections::HashSet, +} + +type SharedState = Arc>; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let state: SharedState = Arc::new(Mutex::new(ServerState::default())); + + let app = Router::new() + .route("/v1/create_table", post(create_table)) + .route("/v1/drop_table", post(drop_table)) + .route("/v1/put_batches", post(put_batches)) + .route("/v1/checkpoint", post(checkpoint)) + .route("/v1/get_last_checkpoint", post(get_last_checkpoint)) + .with_state(state); + + let addr = "0.0.0.0:9999"; + tracing::info!("mock indexer server listening on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} + +async fn create_table( + State(state): State, + body: Bytes, +) -> Result<(), StatusCode> { + let req = proto::CreateTableRequest::decode(body) + .map_err(|e| { + tracing::error!("decode error: {}", e); + StatusCode::BAD_REQUEST + })?; + + // arrow_schema field carries raw DDL bytes (no host functions → no Arrow IPC). + // Server is responsible for Arrow conversion if needed. + let ddl_str = String::from_utf8_lossy(&req.arrow_schema); + tracing::info!( + seq = req.sequence_number, + table = %req.table_name, + key = %req.key, + ddl = %ddl_str, + ddl_len = req.arrow_schema.len(), + "CreateTable (raw DDL)", + ); + + let mut s = state.lock().unwrap(); + if s.tables.contains(&req.table_name) { + tracing::warn!(table = %req.table_name, "table already exists"); + // Return 409 Conflict (maps to ALREADY_EXISTS) + return Err(StatusCode::CONFLICT); + } + s.tables.insert(req.table_name); + Ok(()) +} + +async fn drop_table( + State(state): State, + body: Bytes, +) -> Result<(), StatusCode> { + let req = proto::DropTableRequest::decode(body) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + tracing::info!( + seq = req.sequence_number, + table = %req.table_name, + "DropTable", + ); + + let mut s = state.lock().unwrap(); + s.tables.remove(&req.table_name); + Ok(()) +} + +async fn put_batches(body: Bytes) -> Result<(), StatusCode> { + let req = proto::PutBatchesRequest::decode(body) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + // record_batch field carries raw postcard OnChainTable bytes. + for batch in &req.batches { + tracing::info!( + seq = req.sequence_number, + table = %batch.table_name, + postcard_len = batch.record_batch.len(), + "PutBatches (raw postcard OnChainTable)", + ); + } + Ok(()) +} + +async fn checkpoint( + State(state): State, + body: Bytes, +) -> Result<(), StatusCode> { + let req = proto::CheckpointRequest::decode(body) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let mut s = state.lock().unwrap(); + + // Idempotent: accept if already at this checkpoint. + if s.last_checkpoint == Some(req.sequence_number) { + tracing::debug!(seq = req.sequence_number, "checkpoint idempotent — already at this seq"); + return Ok(()); + } + + // Per proto: first checkpoint can be any value; subsequent must be prev+1. + let expected = match s.last_checkpoint { + None => req.sequence_number, // accept any + Some(n) => n + 1, + }; + + if req.sequence_number != expected { + tracing::error!( + seq = req.sequence_number, + expected = expected, + "checkpoint sequence mismatch", + ); + return Err(StatusCode::BAD_REQUEST); + } + + s.last_checkpoint = Some(req.sequence_number); + tracing::info!(seq = req.sequence_number, "Checkpoint"); + Ok(()) +} + +async fn get_last_checkpoint( + State(state): State, +) -> Result { + let s = state.lock().unwrap(); + let resp = proto::GetLastCheckpointResponse { + sequence_number: s.last_checkpoint.unwrap_or(0), + has_checkpoint: s.last_checkpoint.is_some(), + }; + Ok(Bytes::from(resp.encode_to_vec())) +} diff --git a/pallets/block_forwarder/proto/indexer.proto b/pallets/block_forwarder/proto/indexer.proto new file mode 100644 index 00000000..2a93e18e --- /dev/null +++ b/pallets/block_forwarder/proto/indexer.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; + +package io.spaceandtime.indexer; + +// Stripped-down version of the indexer proto for HTTP+protobuf transport. +// The OCW encodes these messages with prost and POSTs them as +// Content-Type: application/x-protobuf to the indexer HTTP endpoint. +// google.protobuf.Empty responses are represented by empty HTTP 200 bodies. + +message CreateTableRequest { + uint64 sequence_number = 1; + string table_name = 2; + bytes arrow_schema = 3; + string key = 4; +} + +message TableBatch { + string table_name = 1; + bytes record_batch = 2; +} + +message PutBatchesRequest { + uint64 sequence_number = 1; + repeated TableBatch batches = 2; +} + +// Control-plane messages use JSON-in-string on the gRPC side, but for HTTP +// transport we use proper protobuf fields to avoid double-encoding. + +message DropTableRequest { + uint64 sequence_number = 1; + string table_name = 2; +} + +message CheckpointRequest { + uint64 sequence_number = 1; +} + +message GetLastCheckpointResponse { + uint64 sequence_number = 1; + bool has_checkpoint = 2; +} diff --git a/pallets/block_forwarder/scripts/configure-ocw.sh b/pallets/block_forwarder/scripts/configure-ocw.sh new file mode 100755 index 00000000..18815f13 --- /dev/null +++ b/pallets/block_forwarder/scripts/configure-ocw.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# +# Configure a running sxt-node's offchain worker by writing the indexer URL +# to offchain persistent storage. Run this AFTER the node is up. +# +# Usage: +# ./configure-ocw.sh [indexer-url] [node-rpc-url] +# +# Defaults: +# indexer-url: http://127.0.0.1:9999 +# node-rpc-url: http://127.0.0.1:9933 + +set -euo pipefail + +INDEXER_URL="${1:-http://127.0.0.1:9999}" +NODE_RPC="${2:-http://127.0.0.1:9944}" + +# Hex-encode a string (UTF-8 bytes → "0x..."). +hex_of() { + printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | sed 's/^/0x/' +} + +# SCALE-encode a Vec: compact length prefix + raw bytes. +# For short strings (<64 bytes) the compact prefix is (len << 2), single byte. +scale_encode_vec_u8() { + local str="$1" + local len=${#str} + if (( len >= 64 )); then + echo "URL too long for simple SCALE encoding (>=64 bytes)" >&2 + exit 1 + fi + # Compact encoding: (len << 2) as a single byte + local prefix_byte + prefix_byte=$(printf '%02x' $((len << 2))) + local body_hex + body_hex=$(printf '%s' "$str" | od -An -tx1 | tr -d ' \n') + printf '0x%s%s' "$prefix_byte" "$body_hex" +} + +KEY_HEX=$(hex_of "block_forwarder::indexer_url") +VALUE_HEX=$(scale_encode_vec_u8 "$INDEXER_URL") + +echo "Node RPC: $NODE_RPC" +echo "Indexer URL: $INDEXER_URL" +echo "Storage key: $KEY_HEX" +echo "Storage val: $VALUE_HEX" +echo + +if ! response=$(curl -sS --fail-with-body --connect-timeout 5 -X POST "$NODE_RPC" \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"offchain_localStorageSet\",\"params\":[\"PERSISTENT\",\"$KEY_HEX\",\"$VALUE_HEX\"]}" 2>&1); then + echo "✗ RPC call failed. Is the node running at $NODE_RPC?" + echo " curl output: $response" + echo " Start the node with: ./target/release/sxt-node --dev --tmp --rpc-methods=unsafe --rpc-cors=all --offchain-worker=always" + exit 1 +fi + +echo "RPC response: $response" + +if echo "$response" | grep -q '"result":null'; then + echo + echo "✓ Indexer URL configured. The OCW will start forwarding on the next block." + echo + echo "Next steps:" + echo " 1. Create a table via Polkadot.js Apps:" + echo " https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9944" + echo " Developer → Sudo → tables.createTables" + echo " 2. Watch the mock server terminal for CreateTable + Checkpoint calls" +else + echo + echo "✗ Configuration failed. Is the node running with --rpc-methods=unsafe?" + echo " offchain_localStorageSet is an unsafe RPC and requires that flag." + exit 1 +fi diff --git a/pallets/block_forwarder/scripts/run-local-demo.sh b/pallets/block_forwarder/scripts/run-local-demo.sh new file mode 100755 index 00000000..58e11b60 --- /dev/null +++ b/pallets/block_forwarder/scripts/run-local-demo.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Run the mock indexer server and sxt-node side by side for local testing. +# Opens each in a tmux pane so you can see both outputs. +# +# Requires: tmux, cargo, wasm32-unknown-unknown target +# +# Usage: ./run-local-demo.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +if ! command -v tmux >/dev/null 2>&1; then + echo "tmux not installed. Install with: brew install tmux" + exit 1 +fi + +SESSION="sxt-ocw-demo" + +if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Session '$SESSION' already exists. Attaching..." + tmux attach -t "$SESSION" + exit 0 +fi + +cd "$REPO_ROOT" + +tmux new-session -d -s "$SESSION" -n mock-server \ + "echo '=== Mock Indexer Server (port 9999) ==='; cargo run -p mock-indexer-server" + +tmux new-window -t "$SESSION" -n node \ + "echo '=== SxT Node (dev mode) ==='; \ + echo 'Waiting 10s for mock server to build...'; sleep 10; \ + ./target/release/sxt-node --dev --tmp --rpc-methods=unsafe --rpc-cors=all --offchain-worker=always" + +tmux new-window -t "$SESSION" -n configure \ + "echo '=== Configure OCW ==='; \ + echo 'Waiting 60s for node to start...'; sleep 60; \ + $SCRIPT_DIR/configure-ocw.sh; \ + echo; echo 'Now create a table via polkadot.js Apps.'; \ + exec bash" + +echo "Started tmux session '$SESSION' with 3 windows:" +echo " 1. mock-server — the HTTP indexer mock" +echo " 2. node — sxt-node in dev mode" +echo " 3. configure — sets the indexer URL via RPC after the node starts" +echo +echo "Attach with: tmux attach -t $SESSION" +echo "Switch windows: Ctrl-b + window number (0, 1, 2)" +echo "Kill session: tmux kill-session -t $SESSION" + +tmux attach -t "$SESSION" diff --git a/pallets/block_forwarder/src/http_client.rs b/pallets/block_forwarder/src/http_client.rs new file mode 100644 index 00000000..1a5d5c3e --- /dev/null +++ b/pallets/block_forwarder/src/http_client.rs @@ -0,0 +1,152 @@ +//! HTTP+protobuf client for the indexer service. +//! +//! Uses `polkadot_sdk::sp_io::offchain::http` to POST protobuf-encoded request bodies to +//! the indexer's HTTP endpoints. Works in both std and wasm (no_std) runtimes. + +use alloc::format; +use alloc::string::String; +use alloc::vec::Vec; + +use prost::Message; +use polkadot_sdk::sp_runtime::offchain::http::Request; +use polkadot_sdk::sp_runtime::offchain::Duration; + +use crate::proto; + +/// Default HTTP request deadline (30 seconds). +const HTTP_TIMEOUT_MS: u64 = 30_000; + +/// Errors from the HTTP client. +#[derive(Debug)] +pub enum Error { + /// Failed to send the HTTP request. + SendFailed, + /// HTTP request timed out. + DeadlineReached, + /// IO or unknown error during HTTP request. + IoError, + /// Server returned a non-200 status code. + Status(u16), + /// Failed to decode the response body. + Decode(prost::DecodeError), +} + +impl From for Error { + fn from(e: prost::DecodeError) -> Self { + Error::Decode(e) + } +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Error::SendFailed => write!(f, "failed to send HTTP request"), + Error::DeadlineReached => write!(f, "HTTP request deadline reached"), + Error::IoError => write!(f, "HTTP IO error"), + Error::Status(code) => write!(f, "server returned status {}", code), + Error::Decode(e) => write!(f, "protobuf decode error: {}", e), + } + } +} + +/// Fetch the last checkpoint from the server. +pub fn get_last_checkpoint(base_url: &str) -> Result, Error> { + let url = format!("{}/v1/get_last_checkpoint", base_url); + let body = post(&url, &[])?; + let resp = proto::GetLastCheckpointResponse::decode(body.as_slice())?; + Ok(resp.has_checkpoint.then_some(resp.sequence_number)) +} + +/// Register a new table. v1 semantics: `arrow_schema` carries raw DDL bytes. +pub fn create_table( + base_url: &str, + sequence_number: u64, + table_name: String, + arrow_schema: Vec, + key: String, +) -> Result<(), Error> { + let req = proto::CreateTableRequest { + sequence_number, + table_name, + arrow_schema, + key, + }; + let url = format!("{}/v1/create_table", base_url); + post(&url, &req.encode_to_vec())?; + Ok(()) +} + +/// Soft-delete a table. +pub fn drop_table( + base_url: &str, + sequence_number: u64, + table_name: String, +) -> Result<(), Error> { + let req = proto::DropTableRequest { + sequence_number, + table_name, + }; + let url = format!("{}/v1/drop_table", base_url); + post(&url, &req.encode_to_vec())?; + Ok(()) +} + +/// Ingest one or more table batches. +pub fn put_batches( + base_url: &str, + sequence_number: u64, + batches: Vec, +) -> Result<(), Error> { + let req = proto::PutBatchesRequest { + sequence_number, + batches, + }; + let url = format!("{}/v1/put_batches", base_url); + post(&url, &req.encode_to_vec())?; + Ok(()) +} + +/// Record a checkpoint for the given sequence number (block number). +pub fn checkpoint(base_url: &str, sequence_number: u64) -> Result<(), Error> { + let req = proto::CheckpointRequest { sequence_number }; + let url = format!("{}/v1/checkpoint", base_url); + post(&url, &req.encode_to_vec())?; + Ok(()) +} + +/// Low-level POST helper. Sends `body` to `url` with +/// `Content-Type: application/x-protobuf` and returns the response body bytes. +fn post(url: &str, body: &[u8]) -> Result, Error> { + let deadline = polkadot_sdk::sp_io::offchain::timestamp().add(Duration::from_millis(HTTP_TIMEOUT_MS)); + + // Pass body chunks as `Vec>`. Empty body → no chunks (otherwise + // `Request::send` would write an empty chunk and then try to finalize + // again, which fails with "body sending already finished"). + let chunks: Vec> = if body.is_empty() { + Vec::new() + } else { + alloc::vec![body.to_vec()] + }; + + let pending = Request::post(url, chunks) + .add_header("Content-Type", "application/x-protobuf") + .deadline(deadline) + .send() + .map_err(|_| Error::SendFailed)?; + + let response = pending + .try_wait(deadline) + .map_err(|_| Error::DeadlineReached)? + .map_err(|e| match e { + polkadot_sdk::sp_runtime::offchain::http::Error::DeadlineReached => Error::DeadlineReached, + polkadot_sdk::sp_runtime::offchain::http::Error::IoError => Error::IoError, + polkadot_sdk::sp_runtime::offchain::http::Error::Unknown => Error::IoError, + })?; + + let status = response.code; + if status != 200 { + return Err(Error::Status(status)); + } + + Ok(response.body().collect()) +} diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/block_forwarder/src/lib.rs new file mode 100644 index 00000000..3699acd0 --- /dev/null +++ b/pallets/block_forwarder/src/lib.rs @@ -0,0 +1,388 @@ +//! # Block Forwarder Pallet +//! +//! Forwards table lifecycle and data events to an external HTTP indexer +//! service using protobuf-over-HTTP. +//! +//! ## Architecture +//! +//! **Producer** (`on_finalize`, runs during block execution including sync): +//! Reads `polkadot_sdk::frame_system::Events`, extracts relevant variants, writes a +//! SCALE-encoded `BlockIndex` to the offchain DB keyed by block number. +//! +//! **Consumer** (`offchain_worker`, fires at chain tip only): +//! Walks the offchain DB from `cursor+1` to `current_block`, forwards each +//! entry via HTTP+protobuf, checkpoints on the server, deletes consumed +//! entries, advances cursor. +//! +//! ## Why two hooks? +//! +//! `on_finalize` can read events (they exist during block execution) but +//! cannot do HTTP. `offchain_worker` can do HTTP but cannot read past events +//! (cleared at next block). The offchain DB bridges them. +//! +//! ## No new host functions +//! +//! Raw DDL bytes and raw postcard `OnChainTable` bytes are shipped as-is. +//! The HTTP server is responsible for Arrow conversion if needed. + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +mod http_client; +mod offchain_index; + +mod proto { + include!(concat!(env!("OUT_DIR"), "/io.spaceandtime.indexer.rs")); +} + +pub use pallet::*; + +/// Offchain local-storage key for the indexer HTTP endpoint URL. +pub const INDEXER_URL_KEY: &[u8] = b"block_forwarder::indexer_url"; + +/// Dedup key column name sent with every `CreateTable` request. +const DEDUP_KEY_COLUMN: &str = "META_ROW_NUMBER"; + +/// Maximum blocks to process per OCW invocation. Controls catch-up speed. +/// At 100 blocks/invocation and 6s block time, catch-up is ~100x realtime. +const MAX_BLOCKS_PER_INVOCATION: u64 = 100; + +#[polkadot_sdk::frame_support::pallet] +pub mod pallet { + use alloc::format; + use alloc::string::String; + use alloc::vec::Vec; + + use polkadot_sdk::frame_support::pallet_prelude::*; + use polkadot_sdk::frame_system::pallet_prelude::*; + use sxt_core::tables::{TableIdentifier, TableType}; + + use crate::offchain_index::{BlockEvent, BlockIndex, CreateEntry, DataEntry}; + + #[pallet::pallet] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: polkadot_sdk::frame_system::Config + pallet_tables::Config { + /// The runtime's overarching event type. + type RuntimeEvent: From> + + From> + + IsType<::RuntimeEvent>; + + /// The `pallet_indexing` pallet as wired in `construct_runtime!`. + /// Used to resolve its pallet index dynamically so we don't have + /// to hard-code the `construct_runtime!` ordering here. + type IndexingPallet: polkadot_sdk::frame_support::traits::PalletInfoAccess; + + /// Variant index of `QuorumReached` within `pallet_indexing::Event`. + /// The runtime may supply this as `ConstU8` or compute it + /// dynamically by encoding a dummy event. Must stay in sync with + /// the order of variants in `pallet_indexing::Event`. + type QuorumReachedVariantIndex: Get; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// The offchain indexer successfully forwarded a block. + BlockForwarded { + /// Block number that was successfully forwarded. + block_number: u64, + }, + /// The offchain indexer encountered an error. + ForwardingError { + /// Block number that the forwarder failed on. + block_number: u64, + }, + } + + #[pallet::hooks] + impl Hooks> for Pallet { + // ─── PRODUCER ─────────────────────────────────────────────────── + // Runs during block execution (including sync). Captures events + // and persists them to the offchain DB for the OCW to consume. + fn on_finalize(n: BlockNumberFor) { + let block_number: u64 = n.try_into().unwrap_or(0); + let index = Self::extract_block_index(); + + if index.is_empty() { + return; + } + + log::debug!( + target: "block_forwarder", + "on_finalize({}): writing {} events to offchain DB", + block_number, + index.events.len(), + ); + + crate::offchain_index::write(block_number, &index); + } + + // ─── CONSUMER ─────────────────────────────────────────────────── + // Fires at chain tip only (not during sync). Drains the offchain + // DB queue, forwards to the HTTP server, deletes consumed entries. + fn offchain_worker(_block_number: BlockNumberFor) { + if let Err(e) = Self::run_consumer() { + log::error!( + target: "block_forwarder", + "offchain indexer error: {:?}", + e, + ); + } + } + } + + impl Pallet { + // ═══════════════════════════════════════════════════════════════ + // PRODUCER: extract events → BlockIndex (called from on_finalize) + // ═══════════════════════════════════════════════════════════════ + + fn extract_block_index() -> BlockIndex { + let mut index = BlockIndex::default(); + + for record in polkadot_sdk::frame_system::Pallet::::read_events_no_consensus() { + Self::try_extract_table_event(&record.event, &mut index); + Self::try_extract_indexing_event(&record.event, &mut index); + } + + index + } + + fn tables_pallet_index() -> u8 { + let dummy_ident = TableIdentifier { + namespace: BoundedVec::try_from(Vec::new()).unwrap(), + name: BoundedVec::try_from(Vec::new()).unwrap(), + }; + let dummy = pallet_tables::Event::::TableDropped( + None, + TableType::Community, + dummy_ident, + sxt_core::tables::Source::default(), + ); + let our_event: ::RuntimeEvent = dummy.into(); + let runtime_event = <::RuntimeEvent as IsType< + ::RuntimeEvent, + >>::into_ref(&our_event); + codec::Encode::encode(runtime_event)[0] + } + + fn try_extract_table_event( + event: &::RuntimeEvent, + index: &mut BlockIndex, + ) { + use codec::Decode; + let encoded = codec::Encode::encode(event); + if encoded.is_empty() || encoded[0] != Self::tables_pallet_index() { + return; + } + let inner = &encoded[1..]; + let Ok(table_event) = pallet_tables::Event::::decode(&mut &inner[..]) else { + return; + }; + match table_event { + pallet_tables::Event::SchemaUpdated(_who, update_list) => { + for update in update_list.iter() { + index.events.push(BlockEvent::Create(CreateEntry { + ident: update.ident.clone(), + ddl: update.create_statement.to_vec(), + })); + } + } + pallet_tables::Event::TablesCreatedWithCommitments { table_list, .. } => { + for req in table_list.iter() { + index.events.push(BlockEvent::Create(CreateEntry { + ident: req.table_name.clone(), + ddl: req.ddl.to_vec(), + })); + } + } + pallet_tables::Event::TableDropped(_who, _table_type, ident, _source) => { + index.events.push(BlockEvent::Drop(ident)); + } + _ => {} + } + } + + fn try_extract_indexing_event( + event: &::RuntimeEvent, + index: &mut BlockIndex, + ) { + use codec::Decode; + use sxt_core::indexing::DataQuorum; + + use polkadot_sdk::frame_support::traits::PalletInfoAccess; + + let encoded = codec::Encode::encode(event); + if encoded.len() < 2 { + return; + } + let indexing_pallet_index = ::index() as u8; + let quorum_variant = T::QuorumReachedVariantIndex::get(); + if encoded[0] != indexing_pallet_index || encoded[1] != quorum_variant { + return; + } + let mut input = &encoded[2..]; + let Ok(quorum) = DataQuorum::< + ::AccountId, + ::Hash, + >::decode(&mut input) else { + return; + }; + let Ok(data) = , + >>::decode(&mut input) else { + log::warn!(target: "block_forwarder", "failed to decode QuorumReached data"); + return; + }; + index.events.push(BlockEvent::Data(DataEntry { + table: quorum.table, + data: data.to_vec(), + })); + } + + // ═══════════════════════════════════════════════════════════════ + // CONSUMER: drain offchain DB → HTTP → delete (called from OCW) + // ═══════════════════════════════════════════════════════════════ + + fn run_consumer() -> Result<(), &'static str> { + use polkadot_sdk::sp_runtime::offchain::storage::StorageValueRef; + + // 1. Check if this node is configured as an indexer. + let url_ref = StorageValueRef::persistent(crate::INDEXER_URL_KEY); + let url_bytes: Option> = url_ref.get::>().ok().flatten(); + + let Some(url_bytes) = url_bytes else { + return Ok(()); + }; + + let url = core::str::from_utf8(&url_bytes) + .map_err(|_| "invalid UTF-8 in indexer URL")?; + + // 2. Current block number. + let current_block: u64 = polkadot_sdk::frame_system::Pallet::::block_number() + .try_into() + .map_err(|_| "block number conversion failed")?; + + // 3. Ask the server for its last checkpoint. The server is the + // sole source of truth — no local cursor. This costs one HTTP + // round-trip per OCW invocation but guarantees we never + // diverge from what the server has actually committed. + let cursor: u64 = match crate::http_client::get_last_checkpoint(url) { + Ok(Some(seq)) => seq, + Ok(None) => 0, + Err(e) => { + log::warn!( + target: "block_forwarder", + "get_last_checkpoint failed: {}; skipping this round", + e, + ); + return Ok(()); + } + }; + + // 4. Walk blocks from cursor+1 to tip, capped. + let start = cursor + 1; + let end = core::cmp::min(current_block, start + crate::MAX_BLOCKS_PER_INVOCATION - 1); + + if start > current_block { + return Ok(()); + } + + log::debug!( + target: "block_forwarder", + "processing blocks {}..={} (server_checkpoint={}, tip={})", + start, end, cursor, current_block, + ); + + for block_num in start..=end { + // Read from offchain DB. + let entry = crate::offchain_index::read(block_num); + + match &entry { + Some(idx) if !idx.is_empty() => { + log::info!( + target: "block_forwarder", + "block {} — {} events to forward", + block_num, + idx.events.len(), + ); + Self::forward_block(url, block_num, idx)?; + } + _ => {} + } + + // Checkpoint on the server (always, even for empty blocks). + crate::http_client::checkpoint(url, block_num) + .map_err(|_| "checkpoint failed")?; + + // Delete consumed entry from offchain DB. + if entry.is_some() { + crate::offchain_index::clear(block_num); + } + } + + Ok(()) + } + + /// Forward a single block's events in deposit order. + fn forward_block( + url: &str, + block_num: u64, + index: &BlockIndex, + ) -> Result<(), &'static str> { + use crate::offchain_index::BlockEvent; + + let seq = block_num; + + for event in &index.events { + match event { + BlockEvent::Drop(ident) => { + let name = Self::fq_name(ident); + crate::http_client::drop_table(url, seq, name) + .map_err(|_| "drop_table failed")?; + } + BlockEvent::Create(entry) => { + let name = Self::fq_name(&entry.ident); + crate::http_client::create_table( + url, + seq, + name, + entry.ddl.clone(), + crate::DEDUP_KEY_COLUMN.into(), + ) + .map_err(|_| "create_table failed")?; + } + BlockEvent::Data(entry) => { + let name = Self::fq_name(&entry.table); + crate::http_client::put_batches( + url, + seq, + alloc::vec![crate::proto::TableBatch { + table_name: name, + record_batch: entry.data.clone(), + }], + ) + .map_err(|_| "put_batches failed")?; + } + } + } + + Ok(()) + } + + fn fq_name(id: &TableIdentifier) -> String { + let ns = core::str::from_utf8(&id.namespace).unwrap_or("?"); + let name = core::str::from_utf8(&id.name).unwrap_or("?"); + format!("{}.{}", ns, name) + } + } +} diff --git a/pallets/block_forwarder/src/mock.rs b/pallets/block_forwarder/src/mock.rs new file mode 100644 index 00000000..3e47c5e8 --- /dev/null +++ b/pallets/block_forwarder/src/mock.rs @@ -0,0 +1,76 @@ +//! Mock runtime for testing the block forwarder pallet. + +use polkadot_sdk::frame_support::derive_impl; +use polkadot_sdk::frame_support::traits::{ConstU8, ConstU128}; +use polkadot_sdk::sp_core::crypto::AccountId32; +use polkadot_sdk::sp_runtime::traits::IdentityLookup; +use polkadot_sdk::sp_runtime::BuildStorage; +use polkadot_sdk::{frame_support, frame_system, pallet_balances, sp_io}; +use proof_of_sql_commitment_map::generic_over_commitment::ConcreteType; +use proof_of_sql_commitment_map::PerCommitmentScheme; + +use crate as pallet_block_forwarder; + +type Block = frame_system::mocking::MockBlock; +type Balance = u128; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + Permissions: pallet_permissions, + Tables: pallet_tables, + Commitments: pallet_commitments, + Balances: pallet_balances, + BlockForwarder: pallet_block_forwarder, + } +); + +#[derive_impl(frame_system::config_preludes::TestDefaultConfig)] +impl frame_system::Config for Test { + type Block = Block; + type AccountId = AccountId32; + type Lookup = IdentityLookup; + type AccountData = pallet_balances::AccountData; +} + +impl pallet_permissions::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +impl pallet_commitments::Config for Test { + const END_ROW_LIMITS_PER_SCHEME: PerCommitmentScheme> = PerCommitmentScheme { + hyper_kzg: 4, + dynamic_dory: 4, + }; +} + +impl pallet_tables::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] +impl pallet_balances::Config for Test { + type AccountStore = System; + type Balance = Balance; + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ConstU128<1>; +} + +impl pallet_block_forwarder::Config for Test { + type RuntimeEvent = RuntimeEvent; + // Tests don't exercise the indexing-event extraction path, so we stand + // in with any pallet that's already in the mock runtime. The variant + // index is arbitrary for the same reason. + type IndexingPallet = Tables; + type QuorumReachedVariantIndex = ConstU8<1>; +} + +/// Build genesis storage for a test externalities. +pub fn new_test_ext() -> sp_io::TestExternalities { + frame_system::GenesisConfig::::default() + .build_storage() + .unwrap() + .into() +} diff --git a/pallets/block_forwarder/src/offchain_index.rs b/pallets/block_forwarder/src/offchain_index.rs new file mode 100644 index 00000000..bc07aea0 --- /dev/null +++ b/pallets/block_forwarder/src/offchain_index.rs @@ -0,0 +1,83 @@ +//! Block-indexed offchain storage for durable event forwarding. +//! +//! `on_finalize` writes a `BlockIndex` entry per block (if non-empty). +//! The OCW reads, forwards, and deletes consumed entries. +//! Events are stored in deposit order to preserve intra-block semantics. + +use alloc::vec::Vec; + +use codec::{Decode, Encode}; +use sxt_core::tables::TableIdentifier; + +/// Key prefix for block-indexed entries in the offchain DB. +const PREFIX: &[u8] = b"block_forwarder::block::"; + +/// Compute the offchain DB key for a given block number. +pub fn key_for_block(block: u64) -> Vec { + let mut k = PREFIX.to_vec(); + k.extend_from_slice(&block.to_be_bytes()); + k +} + +/// A table-creation event. +#[derive(Encode, Decode, Debug, Clone)] +pub struct CreateEntry { + pub ident: TableIdentifier, + pub ddl: Vec, +} + +/// A data-quorum event. +#[derive(Encode, Decode, Debug, Clone)] +pub struct DataEntry { + pub table: TableIdentifier, + pub data: Vec, +} + +/// A single event captured during block execution. Stored in the order +/// events were deposited so the OCW replays them in the correct sequence. +#[derive(Encode, Decode, Debug, Clone)] +pub enum BlockEvent { + /// Table created or schema updated. + Create(CreateEntry), + /// Table dropped. + Drop(TableIdentifier), + /// Data quorum reached (finalized row data). + Data(DataEntry), +} + +/// All relevant events from a single block, in deposit order. +#[derive(Encode, Decode, Debug, Clone, Default)] +pub struct BlockIndex { + pub events: Vec, +} + +impl BlockIndex { + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +/// Write a BlockIndex to the offchain DB. Called from `on_finalize`. +pub fn write(block: u64, index: &BlockIndex) { + let key = key_for_block(block); + polkadot_sdk::sp_io::offchain_index::set(&key, &index.encode()); +} + +/// Read a BlockIndex from the offchain DB. Called from the OCW. +pub fn read(block: u64) -> Option { + let key = key_for_block(block); + let raw = polkadot_sdk::sp_io::offchain::local_storage_get( + polkadot_sdk::sp_core::offchain::StorageKind::PERSISTENT, + &key, + )?; + BlockIndex::decode(&mut &raw[..]).ok() +} + +/// Delete a consumed entry. Called from the OCW after forwarding. +pub fn clear(block: u64) { + let key = key_for_block(block); + polkadot_sdk::sp_io::offchain::local_storage_clear( + polkadot_sdk::sp_core::offchain::StorageKind::PERSISTENT, + &key, + ); +} diff --git a/pallets/block_forwarder/src/tests.rs b/pallets/block_forwarder/src/tests.rs new file mode 100644 index 00000000..cc3d2479 --- /dev/null +++ b/pallets/block_forwarder/src/tests.rs @@ -0,0 +1,302 @@ +//! Integration tests for the block forwarder pallet. +//! +//! The OCW always asks the server for its last checkpoint (no local cursor). +//! Tests pre-populate the offchain DB as if on_finalize had written entries, +//! then verify the OCW reads, forwards in order, and deletes consumed entries. + +use codec::Encode; +use polkadot_sdk::frame_support::traits::Hooks; +use prost::Message; +use polkadot_sdk::sp_core::offchain::testing::{PendingRequest, TestOffchainExt}; +use polkadot_sdk::sp_core::offchain::{OffchainDbExt, OffchainStorage, OffchainWorkerExt}; +use polkadot_sdk::sp_runtime::BoundedVec; +use sxt_core::tables::{TableIdentifier, TableType}; + +use crate::mock::*; +use crate::offchain_index; +use crate::proto; +use crate::INDEXER_URL_KEY; + +type StateArc = std::sync::Arc>; + +const MOCK_URL: &str = "http://127.0.0.1:9999"; + +fn encode_url() -> Vec { + codec::Encode::encode(&MOCK_URL.as_bytes().to_vec()) +} + +fn table_id(namespace: &str, name: &str) -> TableIdentifier { + TableIdentifier { + namespace: BoundedVec::try_from(namespace.as_bytes().to_vec()).unwrap(), + name: BoundedVec::try_from(name.as_bytes().to_vec()).unwrap(), + } +} + +fn expected_request(path: &str, body: Vec, response: Vec) -> PendingRequest { + PendingRequest { + method: "POST".into(), + uri: format!("{}{}", MOCK_URL, path), + headers: vec![("Content-Type".into(), "application/x-protobuf".into())], + body, + sent: true, + response: Some(response), + ..Default::default() + } +} + +fn checkpoint_at_response(seq: u64) -> Vec { + proto::GetLastCheckpointResponse { + sequence_number: seq, + has_checkpoint: true, + } + .encode_to_vec() +} + +fn no_checkpoint_response() -> Vec { + proto::GetLastCheckpointResponse { + sequence_number: 0, + has_checkpoint: false, + } + .encode_to_vec() +} + +fn setup_with_url() -> (polkadot_sdk::sp_io::TestExternalities, StateArc) { + let mut ext = new_test_ext(); + let (offchain, state) = TestOffchainExt::new(); + ext.register_extension(OffchainWorkerExt::new(offchain.clone())); + ext.register_extension(OffchainDbExt::new(offchain)); + state + .write() + .persistent_storage + .set(b"", INDEXER_URL_KEY, &encode_url()); + (ext, state) +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +#[test] +fn ocw_skips_when_not_configured() { + let mut ext = new_test_ext(); + let (offchain, _) = TestOffchainExt::new(); + ext.register_extension(OffchainWorkerExt::new(offchain.clone())); + ext.register_extension(OffchainDbExt::new(offchain)); + ext.execute_with(|| { + System::set_block_number(1); + BlockForwarder::offchain_worker(1); + }); +} + +#[test] +fn ocw_forwards_and_deletes_offchain_entry() { + let (mut ext, state) = setup_with_url(); + + let ddl = b"CREATE TABLE PUBLIC.USERS (ID BIGINT NOT NULL)"; + let index = offchain_index::BlockIndex { + events: vec![offchain_index::BlockEvent::Create( + offchain_index::CreateEntry { + ident: table_id("PUBLIC", "USERS"), + ddl: ddl.to_vec(), + }, + )], + }; + let key = offchain_index::key_for_block(1); + state + .write() + .persistent_storage + .set(b"", &key, &index.encode()); + + { + let mut s = state.write(); + // 1. get_last_checkpoint → server at block 0 + s.expect_request(expected_request( + "/v1/get_last_checkpoint", + vec![], + no_checkpoint_response(), + )); + // 2. create_table for block 1 + s.expect_request(expected_request( + "/v1/create_table", + proto::CreateTableRequest { + sequence_number: 1, + table_name: "PUBLIC.USERS".into(), + arrow_schema: ddl.to_vec(), + key: "META_ROW_NUMBER".into(), + } + .encode_to_vec(), + vec![], + )); + // 3. checkpoint(1) + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 1 }.encode_to_vec(), + vec![], + )); + } + + ext.execute_with(|| { + System::set_block_number(1); + BlockForwarder::offchain_worker(1); + }); + + // Verify offchain entry was deleted. + let s = state.read(); + assert!( + s.persistent_storage.get(&key).is_none(), + "consumed entry should be deleted" + ); +} + +#[test] +fn ocw_checkpoints_empty_blocks() { + let (mut ext, state) = setup_with_url(); + + { + let mut s = state.write(); + // Server at block 0. + s.expect_request(expected_request( + "/v1/get_last_checkpoint", + vec![], + no_checkpoint_response(), + )); + // Empty block 1 → just checkpoint. + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 1 }.encode_to_vec(), + vec![], + )); + } + + ext.execute_with(|| { + System::set_block_number(1); + BlockForwarder::offchain_worker(1); + }); +} + +#[test] +fn ocw_resumes_from_server_checkpoint() { + let (mut ext, state) = setup_with_url(); + + // Server already at block 5. Block 6 has a drop event. + let index = offchain_index::BlockIndex { + events: vec![offchain_index::BlockEvent::Drop(table_id("NS", "OLD"))], + }; + state.write().persistent_storage.set( + b"", + &offchain_index::key_for_block(6), + &index.encode(), + ); + + { + let mut s = state.write(); + s.expect_request(expected_request( + "/v1/get_last_checkpoint", + vec![], + checkpoint_at_response(5), + )); + s.expect_request(expected_request( + "/v1/drop_table", + proto::DropTableRequest { + sequence_number: 6, + table_name: "NS.OLD".into(), + } + .encode_to_vec(), + vec![], + )); + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 6 }.encode_to_vec(), + vec![], + )); + } + + ext.execute_with(|| { + System::set_block_number(6); + BlockForwarder::offchain_worker(6); + }); +} + +#[test] +fn ocw_processes_multiple_blocks_in_order() { + let (mut ext, state) = setup_with_url(); + + // Block 1: drop T1. Block 2: create T2. Block 3: empty. + state.write().persistent_storage.set( + b"", + &offchain_index::key_for_block(1), + &offchain_index::BlockIndex { + events: vec![offchain_index::BlockEvent::Drop(table_id("NS", "T1"))], + } + .encode(), + ); + state.write().persistent_storage.set( + b"", + &offchain_index::key_for_block(2), + &offchain_index::BlockIndex { + events: vec![offchain_index::BlockEvent::Create( + offchain_index::CreateEntry { + ident: table_id("NS", "T2"), + ddl: b"CREATE TABLE NS.T2 (X INT NOT NULL)".to_vec(), + }, + )], + } + .encode(), + ); + + { + let mut s = state.write(); + // get_last_checkpoint → server at 0 + s.expect_request(expected_request( + "/v1/get_last_checkpoint", + vec![], + no_checkpoint_response(), + )); + // Block 1: drop + checkpoint + s.expect_request(expected_request( + "/v1/drop_table", + proto::DropTableRequest { + sequence_number: 1, + table_name: "NS.T1".into(), + } + .encode_to_vec(), + vec![], + )); + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 1 }.encode_to_vec(), + vec![], + )); + // Block 2: create + checkpoint + s.expect_request(expected_request( + "/v1/create_table", + proto::CreateTableRequest { + sequence_number: 2, + table_name: "NS.T2".into(), + arrow_schema: b"CREATE TABLE NS.T2 (X INT NOT NULL)".to_vec(), + key: "META_ROW_NUMBER".into(), + } + .encode_to_vec(), + vec![], + )); + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 2 }.encode_to_vec(), + vec![], + )); + // Block 3: empty, just checkpoint + s.expect_request(expected_request( + "/v1/checkpoint", + proto::CheckpointRequest { sequence_number: 3 }.encode_to_vec(), + vec![], + )); + } + + ext.execute_with(|| { + System::set_block_number(3); + BlockForwarder::offchain_worker(3); + }); + + // Both consumed entries should be deleted. + let s = state.read(); + assert!(s.persistent_storage.get(&offchain_index::key_for_block(1)).is_none()); + assert!(s.persistent_storage.get(&offchain_index::key_for_block(2)).is_none()); +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 9da65324..c2601453 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -85,6 +85,7 @@ pallet-zkpay.workspace = true pallet-rewards.workspace = true pallet-system-tables.workspace = true pallet-system-contracts.workspace = true +pallet-block-forwarder.workspace = true proof-of-sql-commitment-map.workspace = true sxt-core.workspace = true @@ -101,6 +102,7 @@ std = [ "pallet-keystore/std", "pallet-attestation/std", "pallet-indexing/std", + "pallet-block-forwarder/std", "pallet-tables/std", "pallet-permissions/std", "pallet-system-tables/std", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index cbca130d..649c43ab 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -139,6 +139,7 @@ use proof_of_sql_commitment_map::PerCommitmentScheme; use sxt_core::system_tables::ClaimedUnstake; pub use { pallet_attestation, + pallet_block_forwarder, pallet_commitments, pallet_indexing, pallet_keystore, @@ -891,6 +892,14 @@ impl pallet_rewards::Config for Runtime { type MaxPayoutsPerBlock = ConstU32<3>; } +impl pallet_block_forwarder::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type IndexingPallet = Indexing; + // Variant index of `QuorumReached` in `pallet_indexing::Event`. If the + // enum is ever reordered, update this constant. + type QuorumReachedVariantIndex = ConstU8<1>; +} + #[cfg(feature = "runtime-benchmarks")] impl frame_system_benchmarking::Config for Runtime {} @@ -999,6 +1008,8 @@ mod runtime { pub type Rewards = pallet_rewards; #[runtime::pallet_index(110)] pub type ZkPay = pallet_zkpay; + #[runtime::pallet_index(111)] + pub type BlockForwarder = pallet_block_forwarder; } /// The address format for describing accounts. From 8e3bd41169ef4afa5f86c25a294efa1609d6acb6 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Tue, 21 Apr 2026 20:48:52 +0530 Subject: [PATCH 02/13] =?UTF-8?q?docs(block-forwarder):=20correct=20wire?= =?UTF-8?q?=20format=20=E2=80=94=20Arrow=20IPC,=20not=20postcard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pallet ships QuorumReached.data bytes (which pallet_indexing validates as Arrow IPC single-batch stream via record_batch_bytes_dimensions) through the HTTP adapter verbatim. Block-forwarder never decodes them. Updates the stale doc comment in lib.rs and the mock-server log field from 'postcard OnChainTable' to 'Arrow IPC stream'. No runtime behavior change. --- pallets/block_forwarder/mock-server/src/main.rs | 8 +++++--- pallets/block_forwarder/src/lib.rs | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/pallets/block_forwarder/mock-server/src/main.rs b/pallets/block_forwarder/mock-server/src/main.rs index feb06b32..844cca65 100644 --- a/pallets/block_forwarder/mock-server/src/main.rs +++ b/pallets/block_forwarder/mock-server/src/main.rs @@ -116,13 +116,15 @@ async fn put_batches(body: Bytes) -> Result<(), StatusCode> { let req = proto::PutBatchesRequest::decode(body) .map_err(|_| StatusCode::BAD_REQUEST)?; - // record_batch field carries raw postcard OnChainTable bytes. + // record_batch field carries Arrow IPC single-batch stream bytes — + // the same bytes the block-forwarder relays from + // pallet_indexing::QuorumReached.data. for batch in &req.batches { tracing::info!( seq = req.sequence_number, table = %batch.table_name, - postcard_len = batch.record_batch.len(), - "PutBatches (raw postcard OnChainTable)", + ipc_len = batch.record_batch.len(), + "PutBatches (arrow ipc stream)", ); } Ok(()) diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/block_forwarder/src/lib.rs index 3699acd0..5e8f45bb 100644 --- a/pallets/block_forwarder/src/lib.rs +++ b/pallets/block_forwarder/src/lib.rs @@ -22,8 +22,9 @@ //! //! ## No new host functions //! -//! Raw DDL bytes and raw postcard `OnChainTable` bytes are shipped as-is. -//! The HTTP server is responsible for Arrow conversion if needed. +//! Raw DDL bytes (for schemas) and Arrow IPC single-batch stream bytes +//! (for row data, identical to what `pallet_indexing::submit_data.data` +//! carries) are shipped as-is. The HTTP server decodes both. #![cfg_attr(not(feature = "std"), no_std)] From a19ae608fa7faa3321d7f87bd8f65223bd7fbd1f Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Tue, 21 Apr 2026 23:58:52 +0530 Subject: [PATCH 03/13] feat(runtime): resolve pallet_indexing::QuorumReached variant by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hard-coded `QuorumReachedVariantIndex = ConstU8<1>` wiring with a `DynamicQuorumReachedIndex` struct that looks up the variant's index on `pallet_indexing::Event` by name via `scale_info::TypeInfo` metadata that FRAME already derives on every pallet event. Motivation: the hard-coded `1` was a silent-drift hazard. If anyone reorders `pallet_indexing::Event` (e.g., prepends a new variant), the old index becomes wrong and the block-forwarder silently stops matching QuorumReached events — data quietly stops flowing to indexers. Looking it up by name survives reorderings and fails loudly at node boot with a clear message if the variant is ever renamed or removed. The lookup lives entirely in the runtime crate. The pallet's `Config` contract is unchanged (`QuorumReachedVariantIndex: Get`); the pallet and its mock stay exactly as they were. No new deps; scale-info is already a direct runtime dep. Adds a `dynamic_quorum_reached_index_resolves` unit test so a future rename of `QuorumReached` fails at `cargo test` time rather than node boot. --- runtime/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++++--- runtime/src/tests.rs | 12 ++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 649c43ab..5306bbef 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -895,9 +895,44 @@ impl pallet_rewards::Config for Runtime { impl pallet_block_forwarder::Config for Runtime { type RuntimeEvent = RuntimeEvent; type IndexingPallet = Indexing; - // Variant index of `QuorumReached` in `pallet_indexing::Event`. If the - // enum is ever reordered, update this constant. - type QuorumReachedVariantIndex = ConstU8<1>; + type QuorumReachedVariantIndex = DynamicQuorumReachedIndex; +} + +/// Resolves the variant index of `QuorumReached` in +/// `pallet_indexing::Event` by name via `scale_info`, rather than +/// hard-coding an integer that silently drifts if the enum is ever +/// reordered. Panics at boot with a clear message if the variant goes +/// missing or is renamed — loud failure is strictly better than the +/// block-forwarder silently skipping every quorum event forever. +pub struct DynamicQuorumReachedIndex; + +impl polkadot_sdk::frame_support::traits::Get for DynamicQuorumReachedIndex { + fn get() -> u8 { + find_event_variant_index::< + pallet_indexing::Event, + >("QuorumReached") + .expect( + "pallet_indexing::Event must expose a QuorumReached variant; \ + rename or removal is a breaking change to the block-forwarder \ + integration and must be coordinated.", + ) + } +} + +/// Look up an event enum's variant index by name using the `scale_info` +/// metadata FRAME derives on every pallet Event. Returns `None` if the +/// type isn't a variant (non-enum) or the name isn't present. +fn find_event_variant_index( + variant_name: &str, +) -> Option { + match E::type_info().type_def { + scale_info::TypeDef::Variant(ref v) => v + .variants + .iter() + .find(|variant| variant.name == variant_name) + .map(|variant| variant.index), + _ => None, + } } #[cfg(feature = "runtime-benchmarks")] diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs index c7e6d9c8..bfd9f99f 100644 --- a/runtime/src/tests.rs +++ b/runtime/src/tests.rs @@ -18,3 +18,15 @@ fn era_payout_calculation_works() { let single_era_payout = Balance::from(26557152635181379u128); assert_eq!(to_stakers, single_era_payout); } + +#[test] +fn dynamic_quorum_reached_index_resolves() { + use polkadot_sdk::frame_support::traits::Get; + // If `pallet_indexing::Event::QuorumReached` is ever renamed or removed, + // this test panics at `cargo test` time instead of failing silently in + // the block-forwarder at runtime. + let idx = crate::DynamicQuorumReachedIndex::get(); + // The lookup must succeed (get() panics otherwise). We don't assert a + // specific index — reordering the enum is fine, renaming it is not. + assert!(idx < u8::MAX, "sanity-check: variant index should be well-defined"); +} From dd873ed026f3b6cc23c5ef9f697d32f155e1e7f9 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 00:21:30 +0530 Subject: [PATCH 04/13] feat(node): --indexer-url flag seeds block_forwarder OCW storage Adds an `--indexer-url ` CLI flag to sxt-node. When set, the node writes the URL into the block-forwarder OCW's persistent local storage (key: `block_forwarder::indexer_url`) during startup, as a SCALE-encoded `Vec` under the standard offchain STORAGE_PREFIX. Equivalent to running `pallets/block_forwarder/scripts/configure-ocw.sh` via the offchain_localStorageSet RPC, but: - no --rpc-methods=unsafe required; - no second process / second terminal; - seeds before the first block is authored, so no events are missed. If omitted, behaviour is unchanged: OCW is a no-op until the URL is written by some other means (RPC, manual offchain_localStorageSet, etc.). Touches node/src/cli.rs (new CLI arg), node/src/service.rs (`configure_indexer_url` helper invoked from new_full_base). --- node/src/cli.rs | 8 ++++++++ node/src/service.rs | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/node/src/cli.rs b/node/src/cli.rs index 922f8bf4..1b878303 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -19,6 +19,14 @@ pub struct Cli { #[clap(long)] pub event_forwarder_rpc: Option, + /// If set, writes the URL into OCW persistent local storage under + /// `block_forwarder::indexer_url` at startup, telling the + /// block-forwarder OCW where to POST forwarded events. Equivalent + /// to running `scripts/configure-ocw.sh` via the RPC, but without + /// requiring `--rpc-methods=unsafe`. + #[clap(long)] + pub indexer_url: Option, + #[allow(missing_docs)] #[clap(flatten)] pub storage_monitor: sc_storage_monitor::StorageMonitorParams, diff --git a/node/src/service.rs b/node/src/service.rs index f24972b7..5695d85f 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -5,6 +5,7 @@ use std::sync::Arc; use futures::prelude::*; use polkadot_sdk::sc_client_api::{Backend, BlockBackend}; +use polkadot_sdk::sp_core::offchain::{OffchainStorage, STORAGE_PREFIX}; use polkadot_sdk::sc_consensus_babe::{self, SlotProportion}; use polkadot_sdk::sc_network::event::Event; use polkadot_sdk::sc_network::{NetworkBackend, NetworkEventStream}; @@ -75,6 +76,33 @@ pub type TransactionPool = sc_transaction_pool::FullPool; /// imported and generated. const GRANDPA_JUSTIFICATION_PERIOD: u32 = 512; +/// Seed the block-forwarder OCW's persistent local storage with the +/// indexer URL given on the command line. Mirrors the effect of running +/// `scripts/configure-ocw.sh` via RPC, but without requiring +/// `--rpc-methods=unsafe` or a separate second-step. +/// +/// The storage key is +/// `pallet_block_forwarder::INDEXER_URL_KEY = "block_forwarder::indexer_url"`; +/// the value is a SCALE-encoded `Vec` of the URL bytes. +fn configure_indexer_url( + backend: &FullBackend, + url: &str, +) -> Result<(), ServiceError> { + use codec::Encode; + let Some(mut storage) = backend.offchain_storage() else { + return Err(ServiceError::Other( + "backend did not expose an offchain storage handle; \ + cannot apply --indexer-url" + .into(), + )); + }; + let key = sxt_runtime::pallet_block_forwarder::INDEXER_URL_KEY; + let encoded = url.as_bytes().to_vec().encode(); + storage.set(STORAGE_PREFIX, key, &encoded); + eprintln!("block_forwarder: seeded OCW indexer_url = {}", url); + Ok(()) +} + #[allow(clippy::type_complexity)] #[expect( clippy::result_large_err, @@ -290,6 +318,7 @@ pub struct NewFullBase { )] pub fn new_full_base::Hash>>( config: Configuration, + indexer_url: Option, ) -> Result { let role = config.role; let force_authoring = config.force_authoring; @@ -322,6 +351,13 @@ pub fn new_full_base::Hash>>( other: (rpc_builder, import_setup, rpc_setup, mut telemetry, statement_store), } = new_partial(&config)?; + // If `--indexer-url` was given, seed the block-forwarder OCW's local + // storage with it, so the OCW can immediately start forwarding without + // needing `configure-ocw.sh` to run against the RPC. + if let Some(url) = indexer_url.as_deref() { + configure_indexer_url(backend.as_ref(), url)?; + } + let metrics = N::register_notification_metrics( config.prometheus_config.as_ref().map(|cfg| &cfg.registry), ); @@ -603,13 +639,14 @@ pub fn new_full(config: Configuration, cli: Cli) -> Result { - new_full_base::>(config) + new_full_base::>(config, indexer_url) .map(|NewFullBase { task_manager, .. }| task_manager)? } sc_network::config::NetworkBackendType::Litep2p => { - new_full_base::(config) + new_full_base::(config, indexer_url) .map(|NewFullBase { task_manager, .. }| task_manager)? } }; From 1a9b83be7d91108d642503bfa9387da12c70d55d Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 00:29:41 +0530 Subject: [PATCH 05/13] feat(block-forwarder): surface --enable-offchain-indexing requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-forwarder producer writes events via sp_io::offchain_index::set, which is a silent no-op unless --enable-offchain-indexing=true is passed. Until now, forgetting that flag caused forwarding to look healthy on the producer side while nothing arrived at the indexer — a hard-to-debug silent-failure mode. Two changes to turn silent failure into loud failure: 1. node/src/service.rs (new_full_base): - --indexer-url set + --enable-offchain-indexing=false → hard startup error. Unambiguous misconfiguration; boot refuses to proceed. - --indexer-url unset + --enable-offchain-indexing=false → stderr warning at boot. Forwarding will never work even if the URL is written via RPC later; worth surfacing even if the operator hasn't opted into the CLI flag path. 2. pallets/block_forwarder/README.md: New README explaining the three node flags that must be set for forwarding to work (--enable-offchain-indexing, --offchain-worker, --indexer-url), the runtime wiring, the wire data format, the dedup key contract, and the testing options. One-command dev setup example. No behavior change for correctly-configured nodes. Tests unchanged: pallet-block-forwarder 7/7, sxt-runtime 4/4. --- node/src/service.rs | 27 ++++++- pallets/block_forwarder/README.md | 123 ++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 pallets/block_forwarder/README.md diff --git a/node/src/service.rs b/node/src/service.rs index 5695d85f..999c58db 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -351,11 +351,32 @@ pub fn new_full_base::Hash>>( other: (rpc_builder, import_setup, rpc_setup, mut telemetry, statement_store), } = new_partial(&config)?; - // If `--indexer-url` was given, seed the block-forwarder OCW's local - // storage with it, so the OCW can immediately start forwarding without - // needing `configure-ocw.sh` to run against the RPC. + // The block-forwarder pallet's `on_finalize` calls + // `sp_io::offchain_index::set` to hand events to the OCW. That call is a + // silent no-op unless `--enable-offchain-indexing=true` was passed. We + // surface that loudly rather than letting operators discover it by + // watching zero rows arrive at their indexer. if let Some(url) = indexer_url.as_deref() { + if !config.offchain_worker.indexing_enabled { + return Err(ServiceError::Other( + "--indexer-url was set but --enable-offchain-indexing is not \ + true: block-forwarder's on_finalize writes would be silently \ + dropped. Restart with --enable-offchain-indexing=true, or \ + omit --indexer-url." + .into(), + )); + } configure_indexer_url(backend.as_ref(), url)?; + } else if !config.offchain_worker.indexing_enabled { + // Block-forwarder is in the runtime but offchain indexing is off. + // Forwarding can't work even if someone later writes the URL via RPC. + // Loud eprintln! so it shows up even without log filter setup. + eprintln!( + "warning: --enable-offchain-indexing is not true; the \ + block-forwarder OCW cannot forward events even if \ + block_forwarder::indexer_url is set via RPC. Pass \ + --enable-offchain-indexing=true to enable forwarding." + ); } let metrics = N::register_notification_metrics( diff --git a/pallets/block_forwarder/README.md b/pallets/block_forwarder/README.md new file mode 100644 index 00000000..f433c717 --- /dev/null +++ b/pallets/block_forwarder/README.md @@ -0,0 +1,123 @@ +# pallet-block-forwarder + +Offchain worker that forwards table lifecycle and data events to an +external HTTP indexer service using protobuf-over-HTTP. + +Watches for these events from other pallets and relays them: +- `pallet_tables::SchemaUpdated` +- `pallet_tables::TablesCreatedWithCommitments` +- `pallet_tables::TableDropped` +- `pallet_indexing::QuorumReached` + +Wire contract is under `proto/indexer.proto`; five POST endpoints at +`/v1/{create_table,drop_table,put_batches,checkpoint,get_last_checkpoint}`. + +## Operational requirements + +The pallet has **two** node-side flags that must be correctly set for +forwarding to work. Getting either wrong produces a silent no-op on the +producer side — events look like they'd be forwarded but nothing arrives +at the indexer. + +### 1. `--enable-offchain-indexing=true` — **required** + +The producer (`on_finalize` hook) writes events into offchain local +storage via `sp_io::offchain_index::set`. That host function is a +**silent no-op unless `--enable-offchain-indexing=true` is passed on the +command line**. Substrate defaults the flag to `false`. + +Symptom if missing: `on_finalize(N): writing K events to offchain DB` +logs appear normally, but the OCW's `offchain_index::read(N)` returns +`None` and no forwarding happens. + +As of commit `dd873ed`, the node boots with a loud `warning:` to stderr +if this flag is not set; running with `--indexer-url` *without* the flag +is a hard startup error. + +### 2. `--offchain-worker=always` — required on dev nodes + +Substrate defaults offchain-worker scheduling to `when-authority` — +OCWs only fire on validator/collator nodes. For a dev `--dev --tmp` +node, that means zero OCW invocations. Pass `--offchain-worker=always` +to force them to run regardless of role. + +### 3. `--indexer-url ` — optional (but usually what you want) + +Tells the OCW where to POST forwarded events. Mirrors what +`scripts/configure-ocw.sh` does via the `offchain_localStorageSet` RPC, +but: +- Applies before the first block, so no race with early events. +- Doesn't require `--rpc-methods=unsafe` (the RPC is unsafe-gated). +- Doesn't need a second process / second terminal. + +If omitted, the OCW stays dormant until the URL is written some other +way (RPC, a persistent base-path node with the URL already set from a +previous run, etc.). + +## Minimal one-command dev setup + +``` +./target/release/sxt-node \ + --dev --tmp \ + --rpc-cors=all \ + --offchain-worker=always \ + --enable-offchain-indexing=true \ + --indexer-url http://127.0.0.1:9999 +``` + +`--rpc-methods=unsafe` is **not** needed any more for indexer setup — +only include it if you still want the old `configure-ocw.sh` RPC path. + +## Runtime wiring + +Three Config associated types; the runtime provides them: + +```rust +impl pallet_block_forwarder::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + + // Used to resolve pallet_indexing's pallet index dynamically via + // PalletInfoAccess::index(), so reordering construct_runtime! never + // breaks the filter silently. + type IndexingPallet = Indexing; + + // Variant index of `QuorumReached` within pallet_indexing::Event. + // The runtime supplies a resolver that looks the variant up by name + // at startup via scale_info::TypeInfo. See + // `DynamicQuorumReachedIndex` in runtime/src/lib.rs. + type QuorumReachedVariantIndex = DynamicQuorumReachedIndex; +} +``` + +## Data format + +- `CreateTable.arrow_schema`: raw `CREATE TABLE …` DDL bytes (UTF-8). + The HTTP server parses them via `sqlparser` to derive the Arrow schema. +- `PutBatches.record_batch`: Arrow IPC single-batch stream bytes, + identical to what `pallet_indexing::submit_data.data` validates and + what `QuorumReached` relays verbatim. The forwarder never decodes + this payload — it's an opaque pass-through. + +The pallet does **not** introduce any new host functions; everything +goes through already-in-stable `sp_io` APIs (`offchain_index::set`, +`offchain::http::Request`, etc.). + +## Dedup key contract + +Every forwarded table is registered with the dedup key column +`META_ROW_NUMBER`. The HTTP server rejects `CreateTable` requests whose +Arrow schema lacks a `META_ROW_NUMBER BIGINT NOT NULL` column. If your +on-chain table DDLs don't include this column, the first forward +attempt will fail at the server. Either add the column to the DDL or +patch the forwarder's `DEDUP_KEY_COLUMN` constant. + +## Testing + +- `cargo test -p pallet-block-forwarder` — 7 unit/integration tests + covering skip/forward/delete/multi-block/resume. +- `mock-server/` (separate workspace member) — axum-based local HTTP + stub that logs each call; point `--indexer-url` at it for quick local + iteration without a real indexer. +- `scripts/run-local-demo.sh` — tmux orchestrator that brings up the + mock server + node + OCW configure in one go. Pre-`--indexer-url` + workflow; still works if you want to test the RPC path. From a38d5fbc41b4210a4a49a1bcc8118573e4f9543e Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 11:06:57 +0530 Subject: [PATCH 06/13] refactor(block-forwarder): remove mock-server + configure-ocw scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real integration path is now: - prb-service with --features indexer implements the HTTP surface. - --indexer-url flag on sxt-node seeds the OCW storage at startup. - sxt-int-harness (standalone crate) drives chain actions via TOML. Removing the now-redundant local-testing artifacts: - pallets/block_forwarder/mock-server/ (axum stub HTTP server that logged calls; superseded by running the real prb-service). - pallets/block_forwarder/scripts/configure-ocw.sh (SCALE-encoded the URL and called offchain_localStorageSet via RPC; superseded by the --indexer-url CLI flag). - pallets/block_forwarder/scripts/run-local-demo.sh (tmux orchestrator that chained the mock server + node + configure-ocw.sh; no longer has a job). - Workspace-member entry "pallets/block_forwarder/mock-server". README updated to describe the new integration path. Comments in node/src/cli.rs and node/src/service.rs no longer reference configure-ocw.sh. pallet-block-forwarder tests unchanged: 7/7 pass — they use sp_core::offchain::testing::TestOffchainExt, not the mock-server. --- Cargo.lock | 14 -- Cargo.toml | 1 - node/src/cli.rs | 6 +- node/src/service.rs | 5 +- pallets/block_forwarder/README.md | 34 ++-- .../block_forwarder/mock-server/Cargo.toml | 17 -- pallets/block_forwarder/mock-server/build.rs | 6 - .../block_forwarder/mock-server/src/main.rs | 177 ------------------ .../block_forwarder/scripts/configure-ocw.sh | 74 -------- .../block_forwarder/scripts/run-local-demo.sh | 54 ------ 10 files changed, 20 insertions(+), 368 deletions(-) delete mode 100644 pallets/block_forwarder/mock-server/Cargo.toml delete mode 100644 pallets/block_forwarder/mock-server/build.rs delete mode 100644 pallets/block_forwarder/mock-server/src/main.rs delete mode 100755 pallets/block_forwarder/scripts/configure-ocw.sh delete mode 100755 pallets/block_forwarder/scripts/run-local-demo.sh diff --git a/Cargo.lock b/Cargo.lock index b02499c2..adedab32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9570,20 +9570,6 @@ dependencies = [ "sp-runtime", ] -[[package]] -name = "mock-indexer-server" -version = "0.1.0" -dependencies = [ - "axum", - "prost 0.13.5", - "prost-build 0.13.5", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "mockall" version = "0.11.4" diff --git a/Cargo.toml b/Cargo.toml index 14c02ffc..71094998 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,6 @@ members = [ "eth-ecdsa", "arrow-ipc-no-std", "pallets/block_forwarder", - "pallets/block_forwarder/mock-server", ] exclude = [ "utoipa", diff --git a/node/src/cli.rs b/node/src/cli.rs index 1b878303..85b3c87d 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -21,9 +21,9 @@ pub struct Cli { /// If set, writes the URL into OCW persistent local storage under /// `block_forwarder::indexer_url` at startup, telling the - /// block-forwarder OCW where to POST forwarded events. Equivalent - /// to running `scripts/configure-ocw.sh` via the RPC, but without - /// requiring `--rpc-methods=unsafe`. + /// block-forwarder OCW where to POST forwarded events. Seeds the + /// storage before the first block is authored, so no events are + /// missed between node-up and URL-configured. #[clap(long)] pub indexer_url: Option, diff --git a/node/src/service.rs b/node/src/service.rs index 999c58db..74f498cd 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -77,9 +77,8 @@ pub type TransactionPool = sc_transaction_pool::FullPool; const GRANDPA_JUSTIFICATION_PERIOD: u32 = 512; /// Seed the block-forwarder OCW's persistent local storage with the -/// indexer URL given on the command line. Mirrors the effect of running -/// `scripts/configure-ocw.sh` via RPC, but without requiring -/// `--rpc-methods=unsafe` or a separate second-step. +/// indexer URL given on the command line, before the first block is +/// authored. /// /// The storage key is /// `pallet_block_forwarder::INDEXER_URL_KEY = "block_forwarder::indexer_url"`; diff --git a/pallets/block_forwarder/README.md b/pallets/block_forwarder/README.md index f433c717..2c5594f0 100644 --- a/pallets/block_forwarder/README.md +++ b/pallets/block_forwarder/README.md @@ -43,16 +43,14 @@ to force them to run regardless of role. ### 3. `--indexer-url ` — optional (but usually what you want) -Tells the OCW where to POST forwarded events. Mirrors what -`scripts/configure-ocw.sh` does via the `offchain_localStorageSet` RPC, -but: -- Applies before the first block, so no race with early events. -- Doesn't require `--rpc-methods=unsafe` (the RPC is unsafe-gated). -- Doesn't need a second process / second terminal. +Tells the OCW where to POST forwarded events. The node writes the URL +into the OCW's persistent local storage before the first block is +authored, so no events are missed between node-up and URL-configured. If omitted, the OCW stays dormant until the URL is written some other -way (RPC, a persistent base-path node with the URL already set from a -previous run, etc.). +way — e.g. the `offchain_localStorageSet` RPC (requires +`--rpc-methods=unsafe`) or a persistent base-path node carrying the URL +over from a previous run. ## Minimal one-command dev setup @@ -65,9 +63,6 @@ previous run, etc.). --indexer-url http://127.0.0.1:9999 ``` -`--rpc-methods=unsafe` is **not** needed any more for indexer setup — -only include it if you still want the old `configure-ocw.sh` RPC path. - ## Runtime wiring Three Config associated types; the runtime provides them: @@ -113,11 +108,12 @@ patch the forwarder's `DEDUP_KEY_COLUMN` constant. ## Testing -- `cargo test -p pallet-block-forwarder` — 7 unit/integration tests - covering skip/forward/delete/multi-block/resume. -- `mock-server/` (separate workspace member) — axum-based local HTTP - stub that logs each call; point `--indexer-url` at it for quick local - iteration without a real indexer. -- `scripts/run-local-demo.sh` — tmux orchestrator that brings up the - mock server + node + OCW configure in one go. Pre-`--indexer-url` - workflow; still works if you want to test the RPC path. +- **Unit / integration.** `cargo test -p pallet-block-forwarder` — 7 + tests using `sp_core::offchain::testing::TestOffchainExt` to exercise + skip/forward/delete/multi-block/resume paths without a real HTTP + server. +- **End-to-end against a real indexer.** Build and run `prb-service` + with `--features indexer` (see the sxtdb repo), then launch + `sxt-node` with `--indexer-url http://:`. + The harness at `spaceandtimefdn/sxt-int-harness` (standalone crate) + drives table/data actions against the node via a TOML file. diff --git a/pallets/block_forwarder/mock-server/Cargo.toml b/pallets/block_forwarder/mock-server/Cargo.toml deleted file mode 100644 index 49a17961..00000000 --- a/pallets/block_forwarder/mock-server/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "mock-indexer-server" -version = "0.1.0" -edition = "2021" -publish = false - -[dependencies] -axum = { version = "0.8", features = ["tokio"] } -prost = "0.13" -tokio = { version = "1", features = ["full"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" - -[build-dependencies] -prost-build = "0.13" diff --git a/pallets/block_forwarder/mock-server/build.rs b/pallets/block_forwarder/mock-server/build.rs deleted file mode 100644 index 6e0f8fd8..00000000 --- a/pallets/block_forwarder/mock-server/build.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - prost_build::Config::new() - .compile_protos(&["../proto/indexer.proto"], &["../proto"]) - .expect("failed to compile indexer.proto"); - println!("cargo:rerun-if-changed=../proto/indexer.proto"); -} diff --git a/pallets/block_forwarder/mock-server/src/main.rs b/pallets/block_forwarder/mock-server/src/main.rs deleted file mode 100644 index 844cca65..00000000 --- a/pallets/block_forwarder/mock-server/src/main.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Mock HTTP server that mirrors the indexer gRPC service's proto, but over -//! HTTP + protobuf. Used for local testing of the block forwarder pallet. -//! -//! Endpoints: -//! POST /v1/create_table — body: CreateTableRequest (protobuf) -//! POST /v1/drop_table — body: DropTableRequest (protobuf) -//! POST /v1/put_batches — body: PutBatchesRequest (protobuf) -//! POST /v1/checkpoint — body: CheckpointRequest (protobuf) -//! POST /v1/get_last_checkpoint — empty body, response: GetLastCheckpointResponse (protobuf) -//! -//! State is held in memory (not persisted). Checkpoint rule: the first -//! checkpoint accepts any sequence number; subsequent checkpoints must be -//! `last_checkpoint + 1`. Re-posting the current checkpoint is accepted -//! as an idempotent no-op. - -use std::sync::{Arc, Mutex}; - -use axum::body::Bytes; -use axum::extract::State; -use axum::http::StatusCode; -use axum::routing::post; -use axum::Router; -use prost::Message; - -mod proto { - include!(concat!(env!("OUT_DIR"), "/io.spaceandtime.indexer.rs")); -} - -/// In-memory server state. -#[derive(Debug, Default)] -struct ServerState { - /// Last checkpointed sequence number. - last_checkpoint: Option, - /// Known tables (by fully qualified name). - tables: std::collections::HashSet, -} - -type SharedState = Arc>; - -#[tokio::main] -async fn main() { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .init(); - - let state: SharedState = Arc::new(Mutex::new(ServerState::default())); - - let app = Router::new() - .route("/v1/create_table", post(create_table)) - .route("/v1/drop_table", post(drop_table)) - .route("/v1/put_batches", post(put_batches)) - .route("/v1/checkpoint", post(checkpoint)) - .route("/v1/get_last_checkpoint", post(get_last_checkpoint)) - .with_state(state); - - let addr = "0.0.0.0:9999"; - tracing::info!("mock indexer server listening on {}", addr); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); -} - -async fn create_table( - State(state): State, - body: Bytes, -) -> Result<(), StatusCode> { - let req = proto::CreateTableRequest::decode(body) - .map_err(|e| { - tracing::error!("decode error: {}", e); - StatusCode::BAD_REQUEST - })?; - - // arrow_schema field carries raw DDL bytes (no host functions → no Arrow IPC). - // Server is responsible for Arrow conversion if needed. - let ddl_str = String::from_utf8_lossy(&req.arrow_schema); - tracing::info!( - seq = req.sequence_number, - table = %req.table_name, - key = %req.key, - ddl = %ddl_str, - ddl_len = req.arrow_schema.len(), - "CreateTable (raw DDL)", - ); - - let mut s = state.lock().unwrap(); - if s.tables.contains(&req.table_name) { - tracing::warn!(table = %req.table_name, "table already exists"); - // Return 409 Conflict (maps to ALREADY_EXISTS) - return Err(StatusCode::CONFLICT); - } - s.tables.insert(req.table_name); - Ok(()) -} - -async fn drop_table( - State(state): State, - body: Bytes, -) -> Result<(), StatusCode> { - let req = proto::DropTableRequest::decode(body) - .map_err(|_| StatusCode::BAD_REQUEST)?; - - tracing::info!( - seq = req.sequence_number, - table = %req.table_name, - "DropTable", - ); - - let mut s = state.lock().unwrap(); - s.tables.remove(&req.table_name); - Ok(()) -} - -async fn put_batches(body: Bytes) -> Result<(), StatusCode> { - let req = proto::PutBatchesRequest::decode(body) - .map_err(|_| StatusCode::BAD_REQUEST)?; - - // record_batch field carries Arrow IPC single-batch stream bytes — - // the same bytes the block-forwarder relays from - // pallet_indexing::QuorumReached.data. - for batch in &req.batches { - tracing::info!( - seq = req.sequence_number, - table = %batch.table_name, - ipc_len = batch.record_batch.len(), - "PutBatches (arrow ipc stream)", - ); - } - Ok(()) -} - -async fn checkpoint( - State(state): State, - body: Bytes, -) -> Result<(), StatusCode> { - let req = proto::CheckpointRequest::decode(body) - .map_err(|_| StatusCode::BAD_REQUEST)?; - - let mut s = state.lock().unwrap(); - - // Idempotent: accept if already at this checkpoint. - if s.last_checkpoint == Some(req.sequence_number) { - tracing::debug!(seq = req.sequence_number, "checkpoint idempotent — already at this seq"); - return Ok(()); - } - - // Per proto: first checkpoint can be any value; subsequent must be prev+1. - let expected = match s.last_checkpoint { - None => req.sequence_number, // accept any - Some(n) => n + 1, - }; - - if req.sequence_number != expected { - tracing::error!( - seq = req.sequence_number, - expected = expected, - "checkpoint sequence mismatch", - ); - return Err(StatusCode::BAD_REQUEST); - } - - s.last_checkpoint = Some(req.sequence_number); - tracing::info!(seq = req.sequence_number, "Checkpoint"); - Ok(()) -} - -async fn get_last_checkpoint( - State(state): State, -) -> Result { - let s = state.lock().unwrap(); - let resp = proto::GetLastCheckpointResponse { - sequence_number: s.last_checkpoint.unwrap_or(0), - has_checkpoint: s.last_checkpoint.is_some(), - }; - Ok(Bytes::from(resp.encode_to_vec())) -} diff --git a/pallets/block_forwarder/scripts/configure-ocw.sh b/pallets/block_forwarder/scripts/configure-ocw.sh deleted file mode 100755 index 18815f13..00000000 --- a/pallets/block_forwarder/scripts/configure-ocw.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# -# Configure a running sxt-node's offchain worker by writing the indexer URL -# to offchain persistent storage. Run this AFTER the node is up. -# -# Usage: -# ./configure-ocw.sh [indexer-url] [node-rpc-url] -# -# Defaults: -# indexer-url: http://127.0.0.1:9999 -# node-rpc-url: http://127.0.0.1:9933 - -set -euo pipefail - -INDEXER_URL="${1:-http://127.0.0.1:9999}" -NODE_RPC="${2:-http://127.0.0.1:9944}" - -# Hex-encode a string (UTF-8 bytes → "0x..."). -hex_of() { - printf '%s' "$1" | od -An -tx1 | tr -d ' \n' | sed 's/^/0x/' -} - -# SCALE-encode a Vec: compact length prefix + raw bytes. -# For short strings (<64 bytes) the compact prefix is (len << 2), single byte. -scale_encode_vec_u8() { - local str="$1" - local len=${#str} - if (( len >= 64 )); then - echo "URL too long for simple SCALE encoding (>=64 bytes)" >&2 - exit 1 - fi - # Compact encoding: (len << 2) as a single byte - local prefix_byte - prefix_byte=$(printf '%02x' $((len << 2))) - local body_hex - body_hex=$(printf '%s' "$str" | od -An -tx1 | tr -d ' \n') - printf '0x%s%s' "$prefix_byte" "$body_hex" -} - -KEY_HEX=$(hex_of "block_forwarder::indexer_url") -VALUE_HEX=$(scale_encode_vec_u8 "$INDEXER_URL") - -echo "Node RPC: $NODE_RPC" -echo "Indexer URL: $INDEXER_URL" -echo "Storage key: $KEY_HEX" -echo "Storage val: $VALUE_HEX" -echo - -if ! response=$(curl -sS --fail-with-body --connect-timeout 5 -X POST "$NODE_RPC" \ - -H "Content-Type: application/json" \ - -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"offchain_localStorageSet\",\"params\":[\"PERSISTENT\",\"$KEY_HEX\",\"$VALUE_HEX\"]}" 2>&1); then - echo "✗ RPC call failed. Is the node running at $NODE_RPC?" - echo " curl output: $response" - echo " Start the node with: ./target/release/sxt-node --dev --tmp --rpc-methods=unsafe --rpc-cors=all --offchain-worker=always" - exit 1 -fi - -echo "RPC response: $response" - -if echo "$response" | grep -q '"result":null'; then - echo - echo "✓ Indexer URL configured. The OCW will start forwarding on the next block." - echo - echo "Next steps:" - echo " 1. Create a table via Polkadot.js Apps:" - echo " https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9944" - echo " Developer → Sudo → tables.createTables" - echo " 2. Watch the mock server terminal for CreateTable + Checkpoint calls" -else - echo - echo "✗ Configuration failed. Is the node running with --rpc-methods=unsafe?" - echo " offchain_localStorageSet is an unsafe RPC and requires that flag." - exit 1 -fi diff --git a/pallets/block_forwarder/scripts/run-local-demo.sh b/pallets/block_forwarder/scripts/run-local-demo.sh deleted file mode 100755 index 58e11b60..00000000 --- a/pallets/block_forwarder/scripts/run-local-demo.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# -# Run the mock indexer server and sxt-node side by side for local testing. -# Opens each in a tmux pane so you can see both outputs. -# -# Requires: tmux, cargo, wasm32-unknown-unknown target -# -# Usage: ./run-local-demo.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" - -if ! command -v tmux >/dev/null 2>&1; then - echo "tmux not installed. Install with: brew install tmux" - exit 1 -fi - -SESSION="sxt-ocw-demo" - -if tmux has-session -t "$SESSION" 2>/dev/null; then - echo "Session '$SESSION' already exists. Attaching..." - tmux attach -t "$SESSION" - exit 0 -fi - -cd "$REPO_ROOT" - -tmux new-session -d -s "$SESSION" -n mock-server \ - "echo '=== Mock Indexer Server (port 9999) ==='; cargo run -p mock-indexer-server" - -tmux new-window -t "$SESSION" -n node \ - "echo '=== SxT Node (dev mode) ==='; \ - echo 'Waiting 10s for mock server to build...'; sleep 10; \ - ./target/release/sxt-node --dev --tmp --rpc-methods=unsafe --rpc-cors=all --offchain-worker=always" - -tmux new-window -t "$SESSION" -n configure \ - "echo '=== Configure OCW ==='; \ - echo 'Waiting 60s for node to start...'; sleep 60; \ - $SCRIPT_DIR/configure-ocw.sh; \ - echo; echo 'Now create a table via polkadot.js Apps.'; \ - exec bash" - -echo "Started tmux session '$SESSION' with 3 windows:" -echo " 1. mock-server — the HTTP indexer mock" -echo " 2. node — sxt-node in dev mode" -echo " 3. configure — sets the indexer URL via RPC after the node starts" -echo -echo "Attach with: tmux attach -t $SESSION" -echo "Switch windows: Ctrl-b + window number (0, 1, 2)" -echo "Kill session: tmux kill-session -t $SESSION" - -tmux attach -t "$SESSION" From c8e5c2d508fdef7bf4bbbb613ffd2b6adec665a8 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 11:13:46 +0530 Subject: [PATCH 07/13] perf(runtime): cache DynamicQuorumReachedIndex lookup via lazy_static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DynamicQuorumReachedIndex::get()` is called once per pallet_indexing event in block-forwarder's per-event filter (`try_extract_indexing_event`), which is the hot path during on_finalize. Before this change each call re-ran `scale_info::TypeInfo::type_info()`, which builds a fresh `Type` struct with heap-allocated children on every invocation. Wraps the one-time lookup in `lazy_static!` so subsequent calls are a single atomic load. Lookup still runs on first access (panics on rename/remove, as intended) — just doesn't run 10+ times per block. Adds `lazy_static = { workspace = true }` to runtime's deps. The workspace already pins it with `spin_no_std` feature, so this works in both std (native) and no_std (WASM) builds. No behavior change. sxt-runtime 4/4 tests pass, including `dynamic_quorum_reached_index_resolves`. --- Cargo.lock | 1 + runtime/Cargo.toml | 1 + runtime/src/lib.rs | 31 ++++++++++++++++++++----------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index adedab32..e30f5e09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21208,6 +21208,7 @@ name = "sxt-runtime" version = "0.1.0" dependencies = [ "eth-ecdsa", + "lazy_static", "native-api", "pallet-attestation", "pallet-block-forwarder", diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index c2601453..e4dbcbe2 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -88,6 +88,7 @@ pallet-system-contracts.workspace = true pallet-block-forwarder.workspace = true proof-of-sql-commitment-map.workspace = true sxt-core.workspace = true +lazy_static = { workspace = true } [build-dependencies] substrate-wasm-builder = { optional = true, workspace = true, default-features = true } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5306bbef..c778bdc2 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -901,21 +901,30 @@ impl pallet_block_forwarder::Config for Runtime { /// Resolves the variant index of `QuorumReached` in /// `pallet_indexing::Event` by name via `scale_info`, rather than /// hard-coding an integer that silently drifts if the enum is ever -/// reordered. Panics at boot with a clear message if the variant goes -/// missing or is renamed — loud failure is strictly better than the -/// block-forwarder silently skipping every quorum event forever. +/// reordered. Panics on first access with a clear message if the +/// variant goes missing or is renamed — loud failure is strictly better +/// than the block-forwarder silently skipping every quorum event forever. +/// +/// The resolved index is cached in a `lazy_static!` so repeated `get()` +/// calls on the hot path (per-event filtering in block-forwarder) are a +/// single atomic load rather than a fresh `scale_info::type_info()` +/// traversal. pub struct DynamicQuorumReachedIndex; +lazy_static::lazy_static! { + static ref QUORUM_REACHED_VARIANT_INDEX: u8 = find_event_variant_index::< + pallet_indexing::Event, + >("QuorumReached") + .expect( + "pallet_indexing::Event must expose a QuorumReached variant; \ + rename or removal is a breaking change to the block-forwarder \ + integration and must be coordinated.", + ); +} + impl polkadot_sdk::frame_support::traits::Get for DynamicQuorumReachedIndex { fn get() -> u8 { - find_event_variant_index::< - pallet_indexing::Event, - >("QuorumReached") - .expect( - "pallet_indexing::Event must expose a QuorumReached variant; \ - rename or removal is a breaking change to the block-forwarder \ - integration and must be coordinated.", - ) + *QUORUM_REACHED_VARIANT_INDEX } } From a1dd07e984f2e13be81402151368d36638d5255a Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 11:55:17 +0530 Subject: [PATCH 08/13] refactor(block-forwarder): unify pallet-index resolution via PalletInfoAccess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter had two different mechanisms for resolving a pallet's construct_runtime! index: - pallet_indexing → `type IndexingPallet: PalletInfoAccess;` (introduced earlier this session, FRAME-idiomatic, clean). - pallet_tables → `fn tables_pallet_index()` that fabricated a dummy `TableDropped(None, Community, empty_ident, Source::default())`, routed it through the `From` for RuntimeEvent impl, SCALE-encoded the result, and peeked at byte 0. Brittle — every time `TableDropped`'s variant shape changes (it gained a 4th Source field recently), the dummy constructor has to track it. Now both use `PalletInfoAccess`. Adds `TablesPallet` to Config parallel to `IndexingPallet`; runtime wires `type TablesPallet = Tables;` and the mock wires `type TablesPallet = Tables;` as well. The `tables_pallet_index` function is deleted entirely. Separately fixes a typo the pallet picked up during an earlier edit: `TableTzype::Community` → `TableType::Community`. The line is now gone with the dummy constructor, so the typo no longer matters, but the deletion implicitly resolves it. No behavior change. Tests: pallet-block-forwarder 7/7 pass; sxt-runtime checks clean. --- pallets/block_forwarder/src/lib.rs | 29 +++++++++-------------------- pallets/block_forwarder/src/mock.rs | 1 + runtime/src/lib.rs | 1 + 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/block_forwarder/src/lib.rs index 5e8f45bb..9edb0ed0 100644 --- a/pallets/block_forwarder/src/lib.rs +++ b/pallets/block_forwarder/src/lib.rs @@ -63,7 +63,7 @@ pub mod pallet { use polkadot_sdk::frame_support::pallet_prelude::*; use polkadot_sdk::frame_system::pallet_prelude::*; - use sxt_core::tables::{TableIdentifier, TableType}; + use sxt_core::tables::TableIdentifier; use crate::offchain_index::{BlockEvent, BlockIndex, CreateEntry, DataEntry}; @@ -77,6 +77,11 @@ pub mod pallet { + From> + IsType<::RuntimeEvent>; + /// The `pallet_tables` pallet as wired in `construct_runtime!`. + /// Used to resolve its pallet index dynamically so we don't have + /// to hard-code the `construct_runtime!` ordering here. + type TablesPallet: polkadot_sdk::frame_support::traits::PalletInfoAccess; + /// The `pallet_indexing` pallet as wired in `construct_runtime!`. /// Used to resolve its pallet index dynamically so we don't have /// to hard-code the `construct_runtime!` ordering here. @@ -157,31 +162,15 @@ pub mod pallet { index } - fn tables_pallet_index() -> u8 { - let dummy_ident = TableIdentifier { - namespace: BoundedVec::try_from(Vec::new()).unwrap(), - name: BoundedVec::try_from(Vec::new()).unwrap(), - }; - let dummy = pallet_tables::Event::::TableDropped( - None, - TableType::Community, - dummy_ident, - sxt_core::tables::Source::default(), - ); - let our_event: ::RuntimeEvent = dummy.into(); - let runtime_event = <::RuntimeEvent as IsType< - ::RuntimeEvent, - >>::into_ref(&our_event); - codec::Encode::encode(runtime_event)[0] - } - fn try_extract_table_event( event: &::RuntimeEvent, index: &mut BlockIndex, ) { use codec::Decode; + use polkadot_sdk::frame_support::traits::PalletInfoAccess; + let encoded = codec::Encode::encode(event); - if encoded.is_empty() || encoded[0] != Self::tables_pallet_index() { + if encoded.is_empty() || encoded[0] != ::index() as u8 { return; } let inner = &encoded[1..]; diff --git a/pallets/block_forwarder/src/mock.rs b/pallets/block_forwarder/src/mock.rs index 3e47c5e8..4fab5bfa 100644 --- a/pallets/block_forwarder/src/mock.rs +++ b/pallets/block_forwarder/src/mock.rs @@ -60,6 +60,7 @@ impl pallet_balances::Config for Test { impl pallet_block_forwarder::Config for Test { type RuntimeEvent = RuntimeEvent; + type TablesPallet = Tables; // Tests don't exercise the indexing-event extraction path, so we stand // in with any pallet that's already in the mock runtime. The variant // index is arbitrary for the same reason. diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index c778bdc2..4f1b81a9 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -894,6 +894,7 @@ impl pallet_rewards::Config for Runtime { impl pallet_block_forwarder::Config for Runtime { type RuntimeEvent = RuntimeEvent; + type TablesPallet = Tables; type IndexingPallet = Indexing; type QuorumReachedVariantIndex = DynamicQuorumReachedIndex; } From 4547326505c234c4eb87452ced1f7d1b9a59f7c4 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 12:43:00 +0530 Subject: [PATCH 09/13] =?UTF-8?q?docs(block-forwarder):=20fix=20wire-forma?= =?UTF-8?q?t=20description=20=E2=80=94=20postcard,=20not=20Arrow=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier this session I incorrectly updated these docs to say the forwarded row-data bytes were Arrow IPC. Re-tracing the pallet_indexing flow shows that's not what QuorumReached.data carries: indexer → submit_data.data = Arrow IPC bytes chain: validate_data: parse Arrow IPC header (weight accounting, check only) host fn record_batch_to_onchain: Arrow IPC → RecordBatch → OnChainTable process_insert_and_update_commitments: attach meta columns postcard::to_allocvec(&insert_with_meta_columns) ← POSTCARD from here QuorumReached { data: } block-forwarder: opaque relay of postcard bytes to /v1/put_batches The Arrow IPC format only lives on the indexer-side input; the on-chain event data and everything downstream is postcard-encoded OnChainTable. Module header and README data-format section now reflect that. No code change — this is purely a documentation correction. The companion fix on the sxtdb side restores the prb-service decoder to postcard. --- pallets/block_forwarder/README.md | 10 ++++++---- pallets/block_forwarder/src/lib.rs | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/pallets/block_forwarder/README.md b/pallets/block_forwarder/README.md index 2c5594f0..f3ebf886 100644 --- a/pallets/block_forwarder/README.md +++ b/pallets/block_forwarder/README.md @@ -88,10 +88,12 @@ impl pallet_block_forwarder::Config for Runtime { - `CreateTable.arrow_schema`: raw `CREATE TABLE …` DDL bytes (UTF-8). The HTTP server parses them via `sqlparser` to derive the Arrow schema. -- `PutBatches.record_batch`: Arrow IPC single-batch stream bytes, - identical to what `pallet_indexing::submit_data.data` validates and - what `QuorumReached` relays verbatim. The forwarder never decodes - this payload — it's an opaque pass-through. +- `PutBatches.record_batch`: postcard-encoded `OnChainTable` bytes — + what `pallet_indexing::QuorumReached.data` carries. The chain builds + this in `finalize_quorum`: it converts the indexer's Arrow IPC + submission to an `OnChainTable`, appends commitment meta columns, + and postcard-serializes the result. The block-forwarder never + decodes this payload — it's an opaque pass-through to the server. The pallet does **not** introduce any new host functions; everything goes through already-in-stable `sp_io` APIs (`offchain_index::set`, diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/block_forwarder/src/lib.rs index 9edb0ed0..09b78b07 100644 --- a/pallets/block_forwarder/src/lib.rs +++ b/pallets/block_forwarder/src/lib.rs @@ -22,9 +22,11 @@ //! //! ## No new host functions //! -//! Raw DDL bytes (for schemas) and Arrow IPC single-batch stream bytes -//! (for row data, identical to what `pallet_indexing::submit_data.data` -//! carries) are shipped as-is. The HTTP server decodes both. +//! Raw DDL bytes (for schemas) and postcard-encoded `OnChainTable` bytes +//! (for row data — what `pallet_indexing::QuorumReached.data` carries, +//! after the chain has converted the indexer's Arrow IPC submission to +//! an augmented `OnChainTable` and postcard-serialized it in +//! `finalize_quorum`) are shipped as-is. The HTTP server decodes both. #![cfg_attr(not(feature = "std"), no_std)] From aa816a49be030a8bdd5eca637474656d42bf9816 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Wed, 22 Apr 2026 23:46:59 +0530 Subject: [PATCH 10/13] style(block-forwarder): fix clippy warnings and apply rustfmt - Allow dead_code/missing_docs on the auto-generated pallet module and the prost-generated proto submodule. - Allow enum_variant_names on http_client::Error's IoError variant. - Drop unused TableType import in the OCW tests. - Attach the existing result_large_err expectation to configure_indexer_url so CI's -Dclippy::all doesn't regress on it. - cargo f (imports_granularity=Module, group_imports=StdExternalCrate, imports_layout=HorizontalVertical) across our PR files. --- node/src/service.rs | 11 ++++---- pallets/block_forwarder/src/http_client.rs | 16 ++++++------ pallets/block_forwarder/src/lib.rs | 13 +++++----- pallets/block_forwarder/src/mock.rs | 2 +- pallets/block_forwarder/src/tests.rs | 30 ++++++++++++---------- runtime/src/lib.rs | 4 +-- runtime/src/tests.rs | 5 +++- 7 files changed, 44 insertions(+), 37 deletions(-) diff --git a/node/src/service.rs b/node/src/service.rs index 74f498cd..388eba2e 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use futures::prelude::*; use polkadot_sdk::sc_client_api::{Backend, BlockBackend}; -use polkadot_sdk::sp_core::offchain::{OffchainStorage, STORAGE_PREFIX}; use polkadot_sdk::sc_consensus_babe::{self, SlotProportion}; use polkadot_sdk::sc_network::event::Event; use polkadot_sdk::sc_network::{NetworkBackend, NetworkEventStream}; @@ -15,6 +14,7 @@ use polkadot_sdk::sc_service::error::Error as ServiceError; use polkadot_sdk::sc_service::TaskManager; use polkadot_sdk::sc_telemetry::{Telemetry, TelemetryWorker}; use polkadot_sdk::sc_transaction_pool_api::OffchainTransactionPoolFactory; +use polkadot_sdk::sp_core::offchain::{OffchainStorage, STORAGE_PREFIX}; use polkadot_sdk::sp_runtime::traits::Block as BlockT; use polkadot_sdk::{ sc_authority_discovery, @@ -83,10 +83,11 @@ const GRANDPA_JUSTIFICATION_PERIOD: u32 = 512; /// The storage key is /// `pallet_block_forwarder::INDEXER_URL_KEY = "block_forwarder::indexer_url"`; /// the value is a SCALE-encoded `Vec` of the URL bytes. -fn configure_indexer_url( - backend: &FullBackend, - url: &str, -) -> Result<(), ServiceError> { +#[expect( + clippy::result_large_err, + reason = "ServiceError is from substrate and cannot be modified" +)] +fn configure_indexer_url(backend: &FullBackend, url: &str) -> Result<(), ServiceError> { use codec::Encode; let Some(mut storage) = backend.offchain_storage() else { return Err(ServiceError::Other( diff --git a/pallets/block_forwarder/src/http_client.rs b/pallets/block_forwarder/src/http_client.rs index 1a5d5c3e..7a89d848 100644 --- a/pallets/block_forwarder/src/http_client.rs +++ b/pallets/block_forwarder/src/http_client.rs @@ -7,9 +7,9 @@ use alloc::format; use alloc::string::String; use alloc::vec::Vec; -use prost::Message; use polkadot_sdk::sp_runtime::offchain::http::Request; use polkadot_sdk::sp_runtime::offchain::Duration; +use prost::Message; use crate::proto; @@ -18,6 +18,7 @@ const HTTP_TIMEOUT_MS: u64 = 30_000; /// Errors from the HTTP client. #[derive(Debug)] +#[allow(clippy::enum_variant_names)] pub enum Error { /// Failed to send the HTTP request. SendFailed, @@ -77,11 +78,7 @@ pub fn create_table( } /// Soft-delete a table. -pub fn drop_table( - base_url: &str, - sequence_number: u64, - table_name: String, -) -> Result<(), Error> { +pub fn drop_table(base_url: &str, sequence_number: u64, table_name: String) -> Result<(), Error> { let req = proto::DropTableRequest { sequence_number, table_name, @@ -117,7 +114,8 @@ pub fn checkpoint(base_url: &str, sequence_number: u64) -> Result<(), Error> { /// Low-level POST helper. Sends `body` to `url` with /// `Content-Type: application/x-protobuf` and returns the response body bytes. fn post(url: &str, body: &[u8]) -> Result, Error> { - let deadline = polkadot_sdk::sp_io::offchain::timestamp().add(Duration::from_millis(HTTP_TIMEOUT_MS)); + let deadline = + polkadot_sdk::sp_io::offchain::timestamp().add(Duration::from_millis(HTTP_TIMEOUT_MS)); // Pass body chunks as `Vec>`. Empty body → no chunks (otherwise // `Request::send` would write an empty chunk and then try to finalize @@ -138,7 +136,9 @@ fn post(url: &str, body: &[u8]) -> Result, Error> { .try_wait(deadline) .map_err(|_| Error::DeadlineReached)? .map_err(|e| match e { - polkadot_sdk::sp_runtime::offchain::http::Error::DeadlineReached => Error::DeadlineReached, + polkadot_sdk::sp_runtime::offchain::http::Error::DeadlineReached => { + Error::DeadlineReached + } polkadot_sdk::sp_runtime::offchain::http::Error::IoError => Error::IoError, polkadot_sdk::sp_runtime::offchain::http::Error::Unknown => Error::IoError, })?; diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/block_forwarder/src/lib.rs index 09b78b07..1d623ee1 100644 --- a/pallets/block_forwarder/src/lib.rs +++ b/pallets/block_forwarder/src/lib.rs @@ -41,6 +41,8 @@ mod tests; mod http_client; mod offchain_index; +/// Generated protobuf types for the indexer HTTP adapter wire format. +#[allow(missing_docs, clippy::missing_docs_in_private_items)] mod proto { include!(concat!(env!("OUT_DIR"), "/io.spaceandtime.indexer.rs")); } @@ -58,6 +60,7 @@ const DEDUP_KEY_COLUMN: &str = "META_ROW_NUMBER"; const MAX_BLOCKS_PER_INVOCATION: u64 = 100; #[polkadot_sdk::frame_support::pallet] +#[allow(missing_docs, clippy::missing_docs_in_private_items, dead_code)] pub mod pallet { use alloc::format; use alloc::string::String; @@ -208,9 +211,8 @@ pub mod pallet { index: &mut BlockIndex, ) { use codec::Decode; - use sxt_core::indexing::DataQuorum; - use polkadot_sdk::frame_support::traits::PalletInfoAccess; + use sxt_core::indexing::DataQuorum; let encoded = codec::Encode::encode(event); if encoded.len() < 2 { @@ -256,8 +258,8 @@ pub mod pallet { return Ok(()); }; - let url = core::str::from_utf8(&url_bytes) - .map_err(|_| "invalid UTF-8 in indexer URL")?; + let url = + core::str::from_utf8(&url_bytes).map_err(|_| "invalid UTF-8 in indexer URL")?; // 2. Current block number. let current_block: u64 = polkadot_sdk::frame_system::Pallet::::block_number() @@ -313,8 +315,7 @@ pub mod pallet { } // Checkpoint on the server (always, even for empty blocks). - crate::http_client::checkpoint(url, block_num) - .map_err(|_| "checkpoint failed")?; + crate::http_client::checkpoint(url, block_num).map_err(|_| "checkpoint failed")?; // Delete consumed entry from offchain DB. if entry.is_some() { diff --git a/pallets/block_forwarder/src/mock.rs b/pallets/block_forwarder/src/mock.rs index 4fab5bfa..370c69a8 100644 --- a/pallets/block_forwarder/src/mock.rs +++ b/pallets/block_forwarder/src/mock.rs @@ -1,7 +1,7 @@ //! Mock runtime for testing the block forwarder pallet. use polkadot_sdk::frame_support::derive_impl; -use polkadot_sdk::frame_support::traits::{ConstU8, ConstU128}; +use polkadot_sdk::frame_support::traits::{ConstU128, ConstU8}; use polkadot_sdk::sp_core::crypto::AccountId32; use polkadot_sdk::sp_runtime::traits::IdentityLookup; use polkadot_sdk::sp_runtime::BuildStorage; diff --git a/pallets/block_forwarder/src/tests.rs b/pallets/block_forwarder/src/tests.rs index cc3d2479..0d262505 100644 --- a/pallets/block_forwarder/src/tests.rs +++ b/pallets/block_forwarder/src/tests.rs @@ -6,18 +6,17 @@ use codec::Encode; use polkadot_sdk::frame_support::traits::Hooks; -use prost::Message; use polkadot_sdk::sp_core::offchain::testing::{PendingRequest, TestOffchainExt}; use polkadot_sdk::sp_core::offchain::{OffchainDbExt, OffchainStorage, OffchainWorkerExt}; use polkadot_sdk::sp_runtime::BoundedVec; -use sxt_core::tables::{TableIdentifier, TableType}; +use prost::Message; +use sxt_core::tables::TableIdentifier; use crate::mock::*; -use crate::offchain_index; -use crate::proto; -use crate::INDEXER_URL_KEY; +use crate::{offchain_index, proto, INDEXER_URL_KEY}; -type StateArc = std::sync::Arc>; +type StateArc = + std::sync::Arc>; const MOCK_URL: &str = "http://127.0.0.1:9999"; @@ -180,11 +179,10 @@ fn ocw_resumes_from_server_checkpoint() { let index = offchain_index::BlockIndex { events: vec![offchain_index::BlockEvent::Drop(table_id("NS", "OLD"))], }; - state.write().persistent_storage.set( - b"", - &offchain_index::key_for_block(6), - &index.encode(), - ); + state + .write() + .persistent_storage + .set(b"", &offchain_index::key_for_block(6), &index.encode()); { let mut s = state.write(); @@ -297,6 +295,12 @@ fn ocw_processes_multiple_blocks_in_order() { // Both consumed entries should be deleted. let s = state.read(); - assert!(s.persistent_storage.get(&offchain_index::key_for_block(1)).is_none()); - assert!(s.persistent_storage.get(&offchain_index::key_for_block(2)).is_none()); + assert!(s + .persistent_storage + .get(&offchain_index::key_for_block(1)) + .is_none()); + assert!(s + .persistent_storage + .get(&offchain_index::key_for_block(2)) + .is_none()); } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 4f1b81a9..3119a80c 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -932,9 +932,7 @@ impl polkadot_sdk::frame_support::traits::Get for DynamicQuorumReachedIndex /// Look up an event enum's variant index by name using the `scale_info` /// metadata FRAME derives on every pallet Event. Returns `None` if the /// type isn't a variant (non-enum) or the name isn't present. -fn find_event_variant_index( - variant_name: &str, -) -> Option { +fn find_event_variant_index(variant_name: &str) -> Option { match E::type_info().type_def { scale_info::TypeDef::Variant(ref v) => v .variants diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs index bfd9f99f..c94c5a7d 100644 --- a/runtime/src/tests.rs +++ b/runtime/src/tests.rs @@ -28,5 +28,8 @@ fn dynamic_quorum_reached_index_resolves() { let idx = crate::DynamicQuorumReachedIndex::get(); // The lookup must succeed (get() panics otherwise). We don't assert a // specific index — reordering the enum is fine, renaming it is not. - assert!(idx < u8::MAX, "sanity-check: variant index should be well-defined"); + assert!( + idx < u8::MAX, + "sanity-check: variant index should be well-defined" + ); } From 23dd8e55b0ec61dc2b82edaff58b773d2c06e2ae Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Mon, 27 Apr 2026 13:41:54 +0530 Subject: [PATCH 11/13] refactor(prover-db-indexer): rename pallet and address PR review Address review comments on the offchain HTTP forwarder pallet: - Rename pallet from block-forwarder to prover-db-indexer; the old name was too generic for what is specifically forwarding to a prover-db backend (directory, crate, runtime wiring, storage-key prefixes, log targets, mock/test idents, README all moved). - Rename proto file: indexer.proto -> prover-db.proto. - Rename CLI flag and OCW storage key: --indexer-url -> --prover-db-url (PROVER_DB_URL_KEY const + "prover_db_indexer::prover_db_url" key). - Strengthen --prover-db-url type from String to url::Url so an invalid value is rejected at clap-parse time rather than failing on the OCW's first HTTP request. - Drop debug eprintln on successful storage seed (the Result already propagates errors; success is silent). --- Cargo.lock | 39 +++++++++--------- Cargo.toml | 4 +- node/Cargo.toml | 1 + node/src/cli.rs | 9 ++-- node/src/service.rs | 41 +++++++++---------- pallets/block_forwarder/build.rs | 8 ---- .../Cargo.toml | 4 +- .../README.md | 20 ++++----- pallets/prover_db_indexer/build.rs | 8 ++++ .../proto/prover-db.proto} | 0 .../src/http_client.rs | 0 .../src/lib.rs | 24 +++++------ .../src/mock.rs | 8 ++-- .../src/offchain_index.rs | 2 +- .../src/tests.rs | 14 +++---- runtime/Cargo.toml | 4 +- runtime/src/lib.rs | 12 +++--- runtime/src/tests.rs | 2 +- 18 files changed, 102 insertions(+), 98 deletions(-) delete mode 100644 pallets/block_forwarder/build.rs rename pallets/{block_forwarder => prover_db_indexer}/Cargo.toml (94%) rename pallets/{block_forwarder => prover_db_indexer}/README.md (87%) create mode 100644 pallets/prover_db_indexer/build.rs rename pallets/{block_forwarder/proto/indexer.proto => prover_db_indexer/proto/prover-db.proto} (100%) rename pallets/{block_forwarder => prover_db_indexer}/src/http_client.rs (100%) rename pallets/{block_forwarder => prover_db_indexer}/src/lib.rs (95%) rename pallets/{block_forwarder => prover_db_indexer}/src/mock.rs (92%) rename pallets/{block_forwarder => prover_db_indexer}/src/offchain_index.rs (97%) rename pallets/{block_forwarder => prover_db_indexer}/src/tests.rs (96%) diff --git a/Cargo.lock b/Cargo.lock index e30f5e09..db811f9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10732,24 +10732,6 @@ dependencies = [ "sp-state-machine", ] -[[package]] -name = "pallet-block-forwarder" -version = "0.1.0" -dependencies = [ - "log", - "pallet-commitments", - "pallet-permissions", - "pallet-tables", - "parity-scale-codec", - "parking_lot 0.12.3", - "polkadot-sdk 0.9.0", - "proof-of-sql-commitment-map", - "prost 0.13.5", - "prost-build 0.13.5", - "scale-info", - "sxt-core", -] - [[package]] name = "pallet-bounties" version = "37.0.2" @@ -11729,6 +11711,24 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-prover-db-indexer" +version = "0.1.0" +dependencies = [ + "log", + "pallet-commitments", + "pallet-permissions", + "pallet-tables", + "parity-scale-codec", + "parking_lot 0.12.3", + "polkadot-sdk 0.9.0", + "proof-of-sql-commitment-map", + "prost 0.13.5", + "prost-build 0.13.5", + "scale-info", + "sxt-core", +] + [[package]] name = "pallet-proxy" version = "38.0.0" @@ -21201,6 +21201,7 @@ dependencies = [ "substrate-build-script-utils", "sxt-core", "sxt-runtime", + "url", ] [[package]] @@ -21211,11 +21212,11 @@ dependencies = [ "lazy_static", "native-api", "pallet-attestation", - "pallet-block-forwarder", "pallet-commitments", "pallet-indexing", "pallet-keystore", "pallet-permissions", + "pallet-prover-db-indexer", "pallet-rewards", "pallet-smartcontracts", "pallet-system-contracts", diff --git a/Cargo.toml b/Cargo.toml index 71094998..aa653c72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ members = [ "proof-of-sql/commitment-column-mapping", "eth-ecdsa", "arrow-ipc-no-std", - "pallets/block_forwarder", + "pallets/prover_db_indexer", ] exclude = [ "utoipa", @@ -80,7 +80,7 @@ pallet-smartcontracts = { path = "pallets/smartcontracts", default-features = fa sxt-core = { path = "sxt-core", default-features = false } pallet-tables = { path = "pallets/tables", default-features = false } pallet-indexing = { path = "pallets/indexing", default-features = false } -pallet-block-forwarder = { path = "pallets/block_forwarder", default-features = false } +pallet-prover-db-indexer = { path = "pallets/prover_db_indexer", default-features = false } native = { path = "native", default-features = false } native-api = { path = "native-api", default-features = false } num-bigint = { version = "0.4", default-features = false } diff --git a/node/Cargo.toml b/node/Cargo.toml index 228dba5e..0f482e04 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -46,6 +46,7 @@ rand.workspace = true node-rpc.workspace = true serde.workspace = true codec = { workspace = true, default-features = true } +url = { workspace = true } [build-dependencies] substrate-build-script-utils.workspace = true diff --git a/node/src/cli.rs b/node/src/cli.rs index 85b3c87d..2d1b7d4c 100644 --- a/node/src/cli.rs +++ b/node/src/cli.rs @@ -20,12 +20,15 @@ pub struct Cli { pub event_forwarder_rpc: Option, /// If set, writes the URL into OCW persistent local storage under - /// `block_forwarder::indexer_url` at startup, telling the - /// block-forwarder OCW where to POST forwarded events. Seeds the + /// `prover_db_indexer::prover_db_url` at startup, telling the + /// prover-db-indexer OCW where to POST forwarded events. Seeds the /// storage before the first block is authored, so no events are /// missed between node-up and URL-configured. + /// + /// Parsed as a `url::Url` so an invalid value rejects at startup + /// rather than silently failing on the first OCW HTTP request. #[clap(long)] - pub indexer_url: Option, + pub prover_db_url: Option, #[allow(missing_docs)] #[clap(flatten)] diff --git a/node/src/service.rs b/node/src/service.rs index 388eba2e..677b370a 100644 --- a/node/src/service.rs +++ b/node/src/service.rs @@ -76,30 +76,29 @@ pub type TransactionPool = sc_transaction_pool::FullPool; /// imported and generated. const GRANDPA_JUSTIFICATION_PERIOD: u32 = 512; -/// Seed the block-forwarder OCW's persistent local storage with the -/// indexer URL given on the command line, before the first block is +/// Seed the prover-db-indexer OCW's persistent local storage with the +/// prover-db URL given on the command line, before the first block is /// authored. /// /// The storage key is -/// `pallet_block_forwarder::INDEXER_URL_KEY = "block_forwarder::indexer_url"`; +/// `pallet_prover_db_indexer::PROVER_DB_URL_KEY = "prover_db_indexer::prover_db_url"`; /// the value is a SCALE-encoded `Vec` of the URL bytes. #[expect( clippy::result_large_err, reason = "ServiceError is from substrate and cannot be modified" )] -fn configure_indexer_url(backend: &FullBackend, url: &str) -> Result<(), ServiceError> { +fn configure_prover_db_url(backend: &FullBackend, url: &url::Url) -> Result<(), ServiceError> { use codec::Encode; let Some(mut storage) = backend.offchain_storage() else { return Err(ServiceError::Other( "backend did not expose an offchain storage handle; \ - cannot apply --indexer-url" + cannot apply --prover-db-url" .into(), )); }; - let key = sxt_runtime::pallet_block_forwarder::INDEXER_URL_KEY; - let encoded = url.as_bytes().to_vec().encode(); + let key = sxt_runtime::pallet_prover_db_indexer::PROVER_DB_URL_KEY; + let encoded = url.as_str().as_bytes().to_vec().encode(); storage.set(STORAGE_PREFIX, key, &encoded); - eprintln!("block_forwarder: seeded OCW indexer_url = {}", url); Ok(()) } @@ -318,7 +317,7 @@ pub struct NewFullBase { )] pub fn new_full_base::Hash>>( config: Configuration, - indexer_url: Option, + prover_db_url: Option, ) -> Result { let role = config.role; let force_authoring = config.force_authoring; @@ -351,30 +350,30 @@ pub fn new_full_base::Hash>>( other: (rpc_builder, import_setup, rpc_setup, mut telemetry, statement_store), } = new_partial(&config)?; - // The block-forwarder pallet's `on_finalize` calls + // The prover-db-indexer pallet's `on_finalize` calls // `sp_io::offchain_index::set` to hand events to the OCW. That call is a // silent no-op unless `--enable-offchain-indexing=true` was passed. We // surface that loudly rather than letting operators discover it by // watching zero rows arrive at their indexer. - if let Some(url) = indexer_url.as_deref() { + if let Some(url) = prover_db_url.as_ref() { if !config.offchain_worker.indexing_enabled { return Err(ServiceError::Other( - "--indexer-url was set but --enable-offchain-indexing is not \ - true: block-forwarder's on_finalize writes would be silently \ + "--prover-db-url was set but --enable-offchain-indexing is not \ + true: prover-db-indexer's on_finalize writes would be silently \ dropped. Restart with --enable-offchain-indexing=true, or \ - omit --indexer-url." + omit --prover-db-url." .into(), )); } - configure_indexer_url(backend.as_ref(), url)?; + configure_prover_db_url(backend.as_ref(), url)?; } else if !config.offchain_worker.indexing_enabled { - // Block-forwarder is in the runtime but offchain indexing is off. + // Prover-db-indexer is in the runtime but offchain indexing is off. // Forwarding can't work even if someone later writes the URL via RPC. // Loud eprintln! so it shows up even without log filter setup. eprintln!( "warning: --enable-offchain-indexing is not true; the \ - block-forwarder OCW cannot forward events even if \ - block_forwarder::indexer_url is set via RPC. Pass \ + prover-db-indexer OCW cannot forward events even if \ + prover_db_indexer::prover_db_url is set via RPC. Pass \ --enable-offchain-indexing=true to enable forwarding." ); } @@ -660,14 +659,14 @@ pub fn new_full(config: Configuration, cli: Cli) -> Result { - new_full_base::>(config, indexer_url) + new_full_base::>(config, prover_db_url) .map(|NewFullBase { task_manager, .. }| task_manager)? } sc_network::config::NetworkBackendType::Litep2p => { - new_full_base::(config, indexer_url) + new_full_base::(config, prover_db_url) .map(|NewFullBase { task_manager, .. }| task_manager)? } }; diff --git a/pallets/block_forwarder/build.rs b/pallets/block_forwarder/build.rs deleted file mode 100644 index cc6a6b0e..00000000 --- a/pallets/block_forwarder/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Compiles `proto/indexer.proto` into Rust types via `prost_build`. - -fn main() { - prost_build::Config::new() - .compile_protos(&["proto/indexer.proto"], &["proto"]) - .expect("failed to compile proto/indexer.proto"); - println!("cargo:rerun-if-changed=proto/indexer.proto"); -} diff --git a/pallets/block_forwarder/Cargo.toml b/pallets/prover_db_indexer/Cargo.toml similarity index 94% rename from pallets/block_forwarder/Cargo.toml rename to pallets/prover_db_indexer/Cargo.toml index 641c8612..886ba106 100644 --- a/pallets/block_forwarder/Cargo.toml +++ b/pallets/prover_db_indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "pallet-block-forwarder" -description = "Offchain worker that forwards table lifecycle and data events to an external HTTP indexer service." +name = "pallet-prover-db-indexer" +description = "Offchain worker that forwards table lifecycle and data events to an external prover-db indexer (HTTP)." version = "0.1.0" license = "Unlicense" authors.workspace = true diff --git a/pallets/block_forwarder/README.md b/pallets/prover_db_indexer/README.md similarity index 87% rename from pallets/block_forwarder/README.md rename to pallets/prover_db_indexer/README.md index f3ebf886..5722c555 100644 --- a/pallets/block_forwarder/README.md +++ b/pallets/prover_db_indexer/README.md @@ -1,7 +1,7 @@ -# pallet-block-forwarder +# pallet-prover-db-indexer Offchain worker that forwards table lifecycle and data events to an -external HTTP indexer service using protobuf-over-HTTP. +external prover-db indexer (HTTP) using protobuf-over-HTTP. Watches for these events from other pallets and relays them: - `pallet_tables::SchemaUpdated` @@ -9,7 +9,7 @@ Watches for these events from other pallets and relays them: - `pallet_tables::TableDropped` - `pallet_indexing::QuorumReached` -Wire contract is under `proto/indexer.proto`; five POST endpoints at +Wire contract is under `proto/prover-db.proto`; five POST endpoints at `/v1/{create_table,drop_table,put_batches,checkpoint,get_last_checkpoint}`. ## Operational requirements @@ -31,7 +31,7 @@ logs appear normally, but the OCW's `offchain_index::read(N)` returns `None` and no forwarding happens. As of commit `dd873ed`, the node boots with a loud `warning:` to stderr -if this flag is not set; running with `--indexer-url` *without* the flag +if this flag is not set; running with `--prover-db-url` *without* the flag is a hard startup error. ### 2. `--offchain-worker=always` — required on dev nodes @@ -41,7 +41,7 @@ OCWs only fire on validator/collator nodes. For a dev `--dev --tmp` node, that means zero OCW invocations. Pass `--offchain-worker=always` to force them to run regardless of role. -### 3. `--indexer-url ` — optional (but usually what you want) +### 3. `--prover-db-url ` — optional (but usually what you want) Tells the OCW where to POST forwarded events. The node writes the URL into the OCW's persistent local storage before the first block is @@ -60,7 +60,7 @@ over from a previous run. --rpc-cors=all \ --offchain-worker=always \ --enable-offchain-indexing=true \ - --indexer-url http://127.0.0.1:9999 + --prover-db-url http://127.0.0.1:9999 ``` ## Runtime wiring @@ -68,7 +68,7 @@ over from a previous run. Three Config associated types; the runtime provides them: ```rust -impl pallet_block_forwarder::Config for Runtime { +impl pallet_prover_db_indexer::Config for Runtime { type RuntimeEvent = RuntimeEvent; // Used to resolve pallet_indexing's pallet index dynamically via @@ -92,7 +92,7 @@ impl pallet_block_forwarder::Config for Runtime { what `pallet_indexing::QuorumReached.data` carries. The chain builds this in `finalize_quorum`: it converts the indexer's Arrow IPC submission to an `OnChainTable`, appends commitment meta columns, - and postcard-serializes the result. The block-forwarder never + and postcard-serializes the result. The prover-db-indexer never decodes this payload — it's an opaque pass-through to the server. The pallet does **not** introduce any new host functions; everything @@ -110,12 +110,12 @@ patch the forwarder's `DEDUP_KEY_COLUMN` constant. ## Testing -- **Unit / integration.** `cargo test -p pallet-block-forwarder` — 7 +- **Unit / integration.** `cargo test -p pallet-prover-db-indexer` — 7 tests using `sp_core::offchain::testing::TestOffchainExt` to exercise skip/forward/delete/multi-block/resume paths without a real HTTP server. - **End-to-end against a real indexer.** Build and run `prb-service` with `--features indexer` (see the sxtdb repo), then launch - `sxt-node` with `--indexer-url http://:`. + `sxt-node` with `--prover-db-url http://:`. The harness at `spaceandtimefdn/sxt-int-harness` (standalone crate) drives table/data actions against the node via a TOML file. diff --git a/pallets/prover_db_indexer/build.rs b/pallets/prover_db_indexer/build.rs new file mode 100644 index 00000000..58595a6c --- /dev/null +++ b/pallets/prover_db_indexer/build.rs @@ -0,0 +1,8 @@ +//! Compiles `proto/prover-db.proto` into Rust types via `prost_build`. + +fn main() { + prost_build::Config::new() + .compile_protos(&["proto/prover-db.proto"], &["proto"]) + .expect("failed to compile proto/prover-db.proto"); + println!("cargo:rerun-if-changed=proto/prover-db.proto"); +} diff --git a/pallets/block_forwarder/proto/indexer.proto b/pallets/prover_db_indexer/proto/prover-db.proto similarity index 100% rename from pallets/block_forwarder/proto/indexer.proto rename to pallets/prover_db_indexer/proto/prover-db.proto diff --git a/pallets/block_forwarder/src/http_client.rs b/pallets/prover_db_indexer/src/http_client.rs similarity index 100% rename from pallets/block_forwarder/src/http_client.rs rename to pallets/prover_db_indexer/src/http_client.rs diff --git a/pallets/block_forwarder/src/lib.rs b/pallets/prover_db_indexer/src/lib.rs similarity index 95% rename from pallets/block_forwarder/src/lib.rs rename to pallets/prover_db_indexer/src/lib.rs index 1d623ee1..69de9dd3 100644 --- a/pallets/block_forwarder/src/lib.rs +++ b/pallets/prover_db_indexer/src/lib.rs @@ -1,7 +1,7 @@ -//! # Block Forwarder Pallet +//! # Prover-Db Indexer Pallet //! -//! Forwards table lifecycle and data events to an external HTTP indexer -//! service using protobuf-over-HTTP. +//! Forwards table lifecycle and data events to an external prover-db +//! indexer (HTTP) using protobuf-over-HTTP. //! //! ## Architecture //! @@ -49,8 +49,8 @@ mod proto { pub use pallet::*; -/// Offchain local-storage key for the indexer HTTP endpoint URL. -pub const INDEXER_URL_KEY: &[u8] = b"block_forwarder::indexer_url"; +/// Offchain local-storage key for the prover-db indexer HTTP endpoint URL. +pub const PROVER_DB_URL_KEY: &[u8] = b"prover_db_indexer::prover_db_url"; /// Dedup key column name sent with every `CreateTable` request. const DEDUP_KEY_COLUMN: &str = "META_ROW_NUMBER"; @@ -128,7 +128,7 @@ pub mod pallet { } log::debug!( - target: "block_forwarder", + target: "prover_db_indexer", "on_finalize({}): writing {} events to offchain DB", block_number, index.events.len(), @@ -143,7 +143,7 @@ pub mod pallet { fn offchain_worker(_block_number: BlockNumberFor) { if let Err(e) = Self::run_consumer() { log::error!( - target: "block_forwarder", + target: "prover_db_indexer", "offchain indexer error: {:?}", e, ); @@ -234,7 +234,7 @@ pub mod pallet { u8, polkadot_sdk::frame_support::traits::ConstU32<{ sxt_core::indexing::DATA_MAX_LEN }>, >>::decode(&mut input) else { - log::warn!(target: "block_forwarder", "failed to decode QuorumReached data"); + log::warn!(target: "prover_db_indexer", "failed to decode QuorumReached data"); return; }; index.events.push(BlockEvent::Data(DataEntry { @@ -251,7 +251,7 @@ pub mod pallet { use polkadot_sdk::sp_runtime::offchain::storage::StorageValueRef; // 1. Check if this node is configured as an indexer. - let url_ref = StorageValueRef::persistent(crate::INDEXER_URL_KEY); + let url_ref = StorageValueRef::persistent(crate::PROVER_DB_URL_KEY); let url_bytes: Option> = url_ref.get::>().ok().flatten(); let Some(url_bytes) = url_bytes else { @@ -275,7 +275,7 @@ pub mod pallet { Ok(None) => 0, Err(e) => { log::warn!( - target: "block_forwarder", + target: "prover_db_indexer", "get_last_checkpoint failed: {}; skipping this round", e, ); @@ -292,7 +292,7 @@ pub mod pallet { } log::debug!( - target: "block_forwarder", + target: "prover_db_indexer", "processing blocks {}..={} (server_checkpoint={}, tip={})", start, end, cursor, current_block, ); @@ -304,7 +304,7 @@ pub mod pallet { match &entry { Some(idx) if !idx.is_empty() => { log::info!( - target: "block_forwarder", + target: "prover_db_indexer", "block {} — {} events to forward", block_num, idx.events.len(), diff --git a/pallets/block_forwarder/src/mock.rs b/pallets/prover_db_indexer/src/mock.rs similarity index 92% rename from pallets/block_forwarder/src/mock.rs rename to pallets/prover_db_indexer/src/mock.rs index 370c69a8..7c0ac24b 100644 --- a/pallets/block_forwarder/src/mock.rs +++ b/pallets/prover_db_indexer/src/mock.rs @@ -1,4 +1,4 @@ -//! Mock runtime for testing the block forwarder pallet. +//! Mock runtime for testing the prover-db indexer pallet. use polkadot_sdk::frame_support::derive_impl; use polkadot_sdk::frame_support::traits::{ConstU128, ConstU8}; @@ -9,7 +9,7 @@ use polkadot_sdk::{frame_support, frame_system, pallet_balances, sp_io}; use proof_of_sql_commitment_map::generic_over_commitment::ConcreteType; use proof_of_sql_commitment_map::PerCommitmentScheme; -use crate as pallet_block_forwarder; +use crate as pallet_prover_db_indexer; type Block = frame_system::mocking::MockBlock; type Balance = u128; @@ -21,7 +21,7 @@ frame_support::construct_runtime!( Tables: pallet_tables, Commitments: pallet_commitments, Balances: pallet_balances, - BlockForwarder: pallet_block_forwarder, + ProverDbIndexer: pallet_prover_db_indexer, } ); @@ -58,7 +58,7 @@ impl pallet_balances::Config for Test { type ExistentialDeposit = ConstU128<1>; } -impl pallet_block_forwarder::Config for Test { +impl pallet_prover_db_indexer::Config for Test { type RuntimeEvent = RuntimeEvent; type TablesPallet = Tables; // Tests don't exercise the indexing-event extraction path, so we stand diff --git a/pallets/block_forwarder/src/offchain_index.rs b/pallets/prover_db_indexer/src/offchain_index.rs similarity index 97% rename from pallets/block_forwarder/src/offchain_index.rs rename to pallets/prover_db_indexer/src/offchain_index.rs index bc07aea0..990de342 100644 --- a/pallets/block_forwarder/src/offchain_index.rs +++ b/pallets/prover_db_indexer/src/offchain_index.rs @@ -10,7 +10,7 @@ use codec::{Decode, Encode}; use sxt_core::tables::TableIdentifier; /// Key prefix for block-indexed entries in the offchain DB. -const PREFIX: &[u8] = b"block_forwarder::block::"; +const PREFIX: &[u8] = b"prover_db_indexer::block::"; /// Compute the offchain DB key for a given block number. pub fn key_for_block(block: u64) -> Vec { diff --git a/pallets/block_forwarder/src/tests.rs b/pallets/prover_db_indexer/src/tests.rs similarity index 96% rename from pallets/block_forwarder/src/tests.rs rename to pallets/prover_db_indexer/src/tests.rs index 0d262505..8933d3ff 100644 --- a/pallets/block_forwarder/src/tests.rs +++ b/pallets/prover_db_indexer/src/tests.rs @@ -13,7 +13,7 @@ use prost::Message; use sxt_core::tables::TableIdentifier; use crate::mock::*; -use crate::{offchain_index, proto, INDEXER_URL_KEY}; +use crate::{offchain_index, proto, PROVER_DB_URL_KEY}; type StateArc = std::sync::Arc>; @@ -67,7 +67,7 @@ fn setup_with_url() -> (polkadot_sdk::sp_io::TestExternalities, StateArc) { state .write() .persistent_storage - .set(b"", INDEXER_URL_KEY, &encode_url()); + .set(b"", PROVER_DB_URL_KEY, &encode_url()); (ext, state) } @@ -81,7 +81,7 @@ fn ocw_skips_when_not_configured() { ext.register_extension(OffchainDbExt::new(offchain)); ext.execute_with(|| { System::set_block_number(1); - BlockForwarder::offchain_worker(1); + ProverDbIndexer::offchain_worker(1); }); } @@ -134,7 +134,7 @@ fn ocw_forwards_and_deletes_offchain_entry() { ext.execute_with(|| { System::set_block_number(1); - BlockForwarder::offchain_worker(1); + ProverDbIndexer::offchain_worker(1); }); // Verify offchain entry was deleted. @@ -167,7 +167,7 @@ fn ocw_checkpoints_empty_blocks() { ext.execute_with(|| { System::set_block_number(1); - BlockForwarder::offchain_worker(1); + ProverDbIndexer::offchain_worker(1); }); } @@ -209,7 +209,7 @@ fn ocw_resumes_from_server_checkpoint() { ext.execute_with(|| { System::set_block_number(6); - BlockForwarder::offchain_worker(6); + ProverDbIndexer::offchain_worker(6); }); } @@ -290,7 +290,7 @@ fn ocw_processes_multiple_blocks_in_order() { ext.execute_with(|| { System::set_block_number(3); - BlockForwarder::offchain_worker(3); + ProverDbIndexer::offchain_worker(3); }); // Both consumed entries should be deleted. diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index e4dbcbe2..34902511 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -85,7 +85,7 @@ pallet-zkpay.workspace = true pallet-rewards.workspace = true pallet-system-tables.workspace = true pallet-system-contracts.workspace = true -pallet-block-forwarder.workspace = true +pallet-prover-db-indexer.workspace = true proof-of-sql-commitment-map.workspace = true sxt-core.workspace = true lazy_static = { workspace = true } @@ -103,7 +103,7 @@ std = [ "pallet-keystore/std", "pallet-attestation/std", "pallet-indexing/std", - "pallet-block-forwarder/std", + "pallet-prover-db-indexer/std", "pallet-tables/std", "pallet-permissions/std", "pallet-system-tables/std", diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 3119a80c..b1ef71a2 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -139,11 +139,11 @@ use proof_of_sql_commitment_map::PerCommitmentScheme; use sxt_core::system_tables::ClaimedUnstake; pub use { pallet_attestation, - pallet_block_forwarder, pallet_commitments, pallet_indexing, pallet_keystore, pallet_permissions, + pallet_prover_db_indexer, pallet_rewards, pallet_smartcontracts, pallet_system_contracts, @@ -892,7 +892,7 @@ impl pallet_rewards::Config for Runtime { type MaxPayoutsPerBlock = ConstU32<3>; } -impl pallet_block_forwarder::Config for Runtime { +impl pallet_prover_db_indexer::Config for Runtime { type RuntimeEvent = RuntimeEvent; type TablesPallet = Tables; type IndexingPallet = Indexing; @@ -904,10 +904,10 @@ impl pallet_block_forwarder::Config for Runtime { /// hard-coding an integer that silently drifts if the enum is ever /// reordered. Panics on first access with a clear message if the /// variant goes missing or is renamed — loud failure is strictly better -/// than the block-forwarder silently skipping every quorum event forever. +/// than the prover-db-indexer silently skipping every quorum event forever. /// /// The resolved index is cached in a `lazy_static!` so repeated `get()` -/// calls on the hot path (per-event filtering in block-forwarder) are a +/// calls on the hot path (per-event filtering in prover-db-indexer) are a /// single atomic load rather than a fresh `scale_info::type_info()` /// traversal. pub struct DynamicQuorumReachedIndex; @@ -918,7 +918,7 @@ lazy_static::lazy_static! { >("QuorumReached") .expect( "pallet_indexing::Event must expose a QuorumReached variant; \ - rename or removal is a breaking change to the block-forwarder \ + rename or removal is a breaking change to the prover-db-indexer \ integration and must be coordinated.", ); } @@ -1052,7 +1052,7 @@ mod runtime { #[runtime::pallet_index(110)] pub type ZkPay = pallet_zkpay; #[runtime::pallet_index(111)] - pub type BlockForwarder = pallet_block_forwarder; + pub type ProverDbIndexer = pallet_prover_db_indexer; } /// The address format for describing accounts. diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs index c94c5a7d..d51fbddd 100644 --- a/runtime/src/tests.rs +++ b/runtime/src/tests.rs @@ -24,7 +24,7 @@ fn dynamic_quorum_reached_index_resolves() { use polkadot_sdk::frame_support::traits::Get; // If `pallet_indexing::Event::QuorumReached` is ever renamed or removed, // this test panics at `cargo test` time instead of failing silently in - // the block-forwarder at runtime. + // the prover-db-indexer at runtime. let idx = crate::DynamicQuorumReachedIndex::get(); // The lookup must succeed (get() panics otherwise). We don't assert a // specific index — reordering the enum is fine, renaming it is not. From 92504dba94a99dbc678e4af8bb9bc3baacad5918 Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Mon, 27 Apr 2026 16:59:52 +0530 Subject: [PATCH 12/13] refactor(prover-db-indexer): typed event match via supertraits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: instead of SCALE-encoding each runtime event and peeking pallet/variant tag bytes, supertrait the source pallets and let `construct_runtime!`'s generated `TryInto` conversions do typed downcasts. Variant-rename safety is now compile-time, not a `lazy_static!` panic at startup. - Make pallet instanced (`Pallet`, `Config`, `Event`) so we can supertrait `pallet_indexing::Config` (which is itself instanced). - Drop `TablesPallet`, `IndexingPallet`, `QuorumReachedVariantIndex` associated types. Drop runtime's `DynamicQuorumReachedIndex`, `lazy_static!` cache, `Get` impl, and `find_event_variant_index` helper. Drop `lazy_static` dep from runtime. - Add `native_pallet` aliasing module so `construct_runtime!` can refer to `Pallet` instead of carrying the instance type parameter. - Bridge `frame_system::Config::RuntimeEvent` -> our `Config::RuntimeEvent` via explicit `From::from(...)`; same underlying value, distinct types to the type system, joined by `IsType`. - Mock expands from 78 to ~225 lines (mirrors pallet_indexing's mock) — pure trait-impl boilerplate to satisfy the supertrait chain. No test exercised the producer side either before or after this change. --- Cargo.lock | 5 +- pallets/prover_db_indexer/Cargo.toml | 12 +- pallets/prover_db_indexer/src/lib.rs | 106 ++++----- pallets/prover_db_indexer/src/mock.rs | 217 +++++++++++++++--- .../prover_db_indexer/src/native_pallet.rs | 16 ++ runtime/Cargo.toml | 1 - runtime/src/lib.rs | 159 ++----------- runtime/src/tests.rs | 15 -- 8 files changed, 275 insertions(+), 256 deletions(-) create mode 100644 pallets/prover_db_indexer/src/native_pallet.rs diff --git a/Cargo.lock b/Cargo.lock index db811f9e..f13b6167 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11716,9 +11716,13 @@ name = "pallet-prover-db-indexer" version = "0.1.0" dependencies = [ "log", + "native-api", "pallet-commitments", + "pallet-indexing", "pallet-permissions", + "pallet-system-tables", "pallet-tables", + "pallet-zkpay", "parity-scale-codec", "parking_lot 0.12.3", "polkadot-sdk 0.9.0", @@ -21209,7 +21213,6 @@ name = "sxt-runtime" version = "0.1.0" dependencies = [ "eth-ecdsa", - "lazy_static", "native-api", "pallet-attestation", "pallet-commitments", diff --git a/pallets/prover_db_indexer/Cargo.toml b/pallets/prover_db_indexer/Cargo.toml index 886ba106..39a4c4ee 100644 --- a/pallets/prover_db_indexer/Cargo.toml +++ b/pallets/prover_db_indexer/Cargo.toml @@ -22,6 +22,8 @@ polkadot-sdk = { workspace = true, features = [ sxt-core.workspace = true pallet-tables.workspace = true +pallet-indexing.workspace = true +native-api.workspace = true log = { workspace = true, default-features = false } prost = { version = "0.13", default-features = false, features = ["prost-derive"] } @@ -30,11 +32,17 @@ prost-build = "0.13" [dev-dependencies] polkadot-sdk = { workspace = true, features = [ - "sp-core", "sp-io", "sp-runtime", "pallet-balances", + "sp-core", "sp-io", "sp-runtime", "sp-staking", + "pallet-balances", "pallet-staking", "pallet-staking-reward-curve", + "pallet-session", "pallet-timestamp", + "frame-election-provider-support", ]} pallet-permissions = { workspace = true, features = ["std"] } pallet-commitments = { workspace = true, features = ["std"] } pallet-tables = { workspace = true, features = ["std"] } +pallet-indexing = { workspace = true, features = ["std"] } +pallet-system-tables = { workspace = true, features = ["std"] } +pallet-zkpay = { workspace = true, features = ["std"] } proof-of-sql-commitment-map.workspace = true parking_lot = "0.12" @@ -46,6 +54,8 @@ std = [ "scale-info/std", "sxt-core/std", "pallet-tables/std", + "pallet-indexing/std", + "native-api/std", "log/std", "prost/std", ] diff --git a/pallets/prover_db_indexer/src/lib.rs b/pallets/prover_db_indexer/src/lib.rs index 69de9dd3..39feb77d 100644 --- a/pallets/prover_db_indexer/src/lib.rs +++ b/pallets/prover_db_indexer/src/lib.rs @@ -41,6 +41,8 @@ mod tests; mod http_client; mod offchain_index; +pub mod native_pallet; + /// Generated protobuf types for the indexer HTTP adapter wire format. #[allow(missing_docs, clippy::missing_docs_in_private_items)] mod proto { @@ -73,35 +75,24 @@ pub mod pallet { use crate::offchain_index::{BlockEvent, BlockIndex, CreateEntry, DataEntry}; #[pallet::pallet] - pub struct Pallet(_); + pub struct Pallet(_); #[pallet::config] - pub trait Config: polkadot_sdk::frame_system::Config + pallet_tables::Config { - /// The runtime's overarching event type. - type RuntimeEvent: From> - + From> - + IsType<::RuntimeEvent>; - - /// The `pallet_tables` pallet as wired in `construct_runtime!`. - /// Used to resolve its pallet index dynamically so we don't have - /// to hard-code the `construct_runtime!` ordering here. - type TablesPallet: polkadot_sdk::frame_support::traits::PalletInfoAccess; - - /// The `pallet_indexing` pallet as wired in `construct_runtime!`. - /// Used to resolve its pallet index dynamically so we don't have - /// to hard-code the `construct_runtime!` ordering here. - type IndexingPallet: polkadot_sdk::frame_support::traits::PalletInfoAccess; - - /// Variant index of `QuorumReached` within `pallet_indexing::Event`. - /// The runtime may supply this as `ConstU8` or compute it - /// dynamically by encoding a dummy event. Must stay in sync with - /// the order of variants in `pallet_indexing::Event`. - type QuorumReachedVariantIndex: Get; + pub trait Config: + polkadot_sdk::frame_system::Config + pallet_tables::Config + pallet_indexing::Config + { + /// The runtime's overarching event type. Bounds let us downcast + /// it back to the per-pallet typed events without re-encoding, + /// using `TryInto` impls that `construct_runtime!` generates. + type RuntimeEvent: From> + + IsType<::RuntimeEvent> + + TryInto> + + TryInto>; } #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] - pub enum Event { + pub enum Event, I: 'static = ()> { /// The offchain indexer successfully forwarded a block. BlockForwarded { /// Block number that was successfully forwarded. @@ -115,7 +106,7 @@ pub mod pallet { } #[pallet::hooks] - impl Hooks> for Pallet { + impl, I: 'static> Hooks> for Pallet { // ─── PRODUCER ─────────────────────────────────────────────────── // Runs during block execution (including sync). Captures events // and persists them to the offchain DB for the OCW to consume. @@ -151,7 +142,7 @@ pub mod pallet { } } - impl Pallet { + impl, I: 'static> Pallet { // ═══════════════════════════════════════════════════════════════ // PRODUCER: extract events → BlockIndex (called from on_finalize) // ═══════════════════════════════════════════════════════════════ @@ -160,26 +151,27 @@ pub mod pallet { let mut index = BlockIndex::default(); for record in polkadot_sdk::frame_system::Pallet::::read_events_no_consensus() { - Self::try_extract_table_event(&record.event, &mut index); - Self::try_extract_indexing_event(&record.event, &mut index); + // `record.event` is the outer `RuntimeEvent`. We try + // converting it back to each typed pallet event via + // `TryInto` impls generated by `construct_runtime!`. + // Cloning is cheap — events are small SCALE enums — and + // saves us writing two extraction passes back-to-back. + Self::try_extract_table_event(record.event.clone(), &mut index); + Self::try_extract_indexing_event(record.event, &mut index); } index } fn try_extract_table_event( - event: &::RuntimeEvent, + event: ::RuntimeEvent, index: &mut BlockIndex, ) { - use codec::Decode; - use polkadot_sdk::frame_support::traits::PalletInfoAccess; - - let encoded = codec::Encode::encode(event); - if encoded.is_empty() || encoded[0] != ::index() as u8 { - return; - } - let inner = &encoded[1..]; - let Ok(table_event) = pallet_tables::Event::::decode(&mut &inner[..]) else { + // `event` is the frame-system RuntimeEvent. Bounce through our + // Config's RuntimeEvent (same type at runtime, bridged by `IsType`) + // so the `TryInto` bound applies. + let our_event = <>::RuntimeEvent as From<_>>::from(event); + let Ok(table_event): Result, _> = our_event.try_into() else { return; }; match table_event { @@ -207,40 +199,20 @@ pub mod pallet { } fn try_extract_indexing_event( - event: &::RuntimeEvent, + event: ::RuntimeEvent, index: &mut BlockIndex, ) { - use codec::Decode; - use polkadot_sdk::frame_support::traits::PalletInfoAccess; - use sxt_core::indexing::DataQuorum; - - let encoded = codec::Encode::encode(event); - if encoded.len() < 2 { - return; - } - let indexing_pallet_index = ::index() as u8; - let quorum_variant = T::QuorumReachedVariantIndex::get(); - if encoded[0] != indexing_pallet_index || encoded[1] != quorum_variant { - return; - } - let mut input = &encoded[2..]; - let Ok(quorum) = DataQuorum::< - ::AccountId, - ::Hash, - >::decode(&mut input) else { + let our_event = <>::RuntimeEvent as From<_>>::from(event); + let Ok(indexing_event): Result, _> = our_event.try_into() + else { return; }; - let Ok(data) = , - >>::decode(&mut input) else { - log::warn!(target: "prover_db_indexer", "failed to decode QuorumReached data"); - return; - }; - index.events.push(BlockEvent::Data(DataEntry { - table: quorum.table, - data: data.to_vec(), - })); + if let pallet_indexing::Event::QuorumReached { quorum, data } = indexing_event { + index.events.push(BlockEvent::Data(DataEntry { + table: quorum.table, + data: data.to_vec(), + })); + } } // ═══════════════════════════════════════════════════════════════ diff --git a/pallets/prover_db_indexer/src/mock.rs b/pallets/prover_db_indexer/src/mock.rs index 7c0ac24b..b9139ce0 100644 --- a/pallets/prover_db_indexer/src/mock.rs +++ b/pallets/prover_db_indexer/src/mock.rs @@ -1,77 +1,224 @@ //! Mock runtime for testing the prover-db indexer pallet. +//! +//! Mirrors `pallet_indexing`'s mock because our `Config: pallet_indexing::Config` +//! supertrait pulls in the same transitive dependency chain (system_tables +//! → session/staking/balances/zkpay/timestamp). The mock exists purely to +//! satisfy those trait bounds at compile time — no test actually exercises +//! the producer side that consumes events from these pallets. -use polkadot_sdk::frame_support::derive_impl; -use polkadot_sdk::frame_support::traits::{ConstU128, ConstU8}; -use polkadot_sdk::sp_core::crypto::AccountId32; -use polkadot_sdk::sp_runtime::traits::IdentityLookup; -use polkadot_sdk::sp_runtime::BuildStorage; -use polkadot_sdk::{frame_support, frame_system, pallet_balances, sp_io}; +use native_api::Api; +use polkadot_sdk::frame_election_provider_support::bounds::{ + ElectionBounds, ElectionBoundsBuilder, +}; +use polkadot_sdk::frame_election_provider_support::{onchain, SequentialPhragmen}; +use polkadot_sdk::frame_support::pallet_prelude::ConstU32; +use polkadot_sdk::frame_support::traits::ConstU128; +use polkadot_sdk::frame_support::{derive_impl, parameter_types}; +use polkadot_sdk::sp_core::{ConstU64, H256}; +use polkadot_sdk::sp_runtime::traits::{IdentityLookup, OpaqueKeys}; +use polkadot_sdk::sp_runtime::{BuildStorage, KeyTypeId}; +use polkadot_sdk::{ + frame_support, frame_system, pallet_balances, pallet_session, pallet_staking, + pallet_staking_reward_curve, pallet_timestamp, sp_core, sp_io, sp_runtime, sp_staking, +}; use proof_of_sql_commitment_map::generic_over_commitment::ConcreteType; use proof_of_sql_commitment_map::PerCommitmentScheme; use crate as pallet_prover_db_indexer; type Block = frame_system::mocking::MockBlock; -type Balance = u128; frame_support::construct_runtime!( - pub enum Test { + pub enum Test + { System: frame_system, + Indexing: pallet_indexing::native_pallet, Permissions: pallet_permissions, - Tables: pallet_tables, Commitments: pallet_commitments, + ZkPay: pallet_zkpay, + Tables: pallet_tables, + Session: pallet_session, + SystemTables: pallet_system_tables, Balances: pallet_balances, - ProverDbIndexer: pallet_prover_db_indexer, + Staking: pallet_staking, + ProverDbIndexer: pallet_prover_db_indexer::native_pallet, } ); +type AccountId = sp_runtime::AccountId32; +type Nonce = u32; +type Balance = u128; + #[derive_impl(frame_system::config_preludes::TestDefaultConfig)] impl frame_system::Config for Test { + type Nonce = Nonce; + type AccountId = AccountId; + type AccountData = pallet_balances::AccountData; type Block = Block; - type AccountId = AccountId32; type Lookup = IdentityLookup; - type AccountData = pallet_balances::AccountData; + type Hash = H256; } -impl pallet_permissions::Config for Test { +impl pallet_balances::Config for Test { + type AccountStore = System; + type Balance = Balance; type RuntimeEvent = RuntimeEvent; + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); type WeightInfo = (); + type DustRemoval = (); + type ExistentialDeposit = ConstU128<1>; + type ReserveIdentifier = (); + type FreezeIdentifier = (); + type MaxLocks = (); + type MaxReserves = (); + type MaxFreezes = (); } -impl pallet_commitments::Config for Test { - const END_ROW_LIMITS_PER_SCHEME: PerCommitmentScheme> = PerCommitmentScheme { - hyper_kzg: 4, - dynamic_dory: 4, - }; +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<5>; + type WeightInfo = (); } -impl pallet_tables::Config for Test { +pallet_staking_reward_curve::build! { + const I_NPOS: sp_runtime::curve::PiecewiseLinear<'static> = curve!( + min_inflation: 0_025_000, + max_inflation: 0_100_000, + ideal_stake: 0_500_000, + falloff: 0_050_000, + max_piece_count: 40, + test_precision: 0_005_000, + ); +} +parameter_types! { + pub const RewardCurve: &'static sp_runtime::curve::PiecewiseLinear<'static> = &I_NPOS; + pub static ElectionsBounds: ElectionBounds = ElectionBoundsBuilder::default().build(); +} + +pub struct OnChainSeqPhragmen; +impl onchain::Config for OnChainSeqPhragmen { + type System = Test; + type Solver = SequentialPhragmen; + type DataProvider = Staking; + type WeightInfo = (); + type MaxWinners = ConstU32<100>; + type Bounds = ElectionsBounds; +} + +#[derive_impl(pallet_staking::config_preludes::TestDefaultConfig)] +impl pallet_staking::Config for Test { + type Currency = Balances; + type CurrencyBalance = ::Balance; + type UnixTime = pallet_timestamp::Pallet; + type AdminOrigin = frame_system::EnsureRoot; + type SessionInterface = Self; + type EraPayout = pallet_staking::ConvertCurve; + type NextNewSession = Session; + type ElectionProvider = onchain::OnChainExecution; + type GenesisElectionProvider = Self::ElectionProvider; + type VoterList = pallet_staking::UseNominatorsAndValidatorsMap; + type TargetList = pallet_staking::UseValidatorsMap; + type CurrencyToVote = sp_staking::currency_to_vote::U128CurrencyToVote; + type NominationsQuota = pallet_staking::FixedNominationsQuota<16>; + type RewardRemainder = (); + type RuntimeEvent = RuntimeEvent; + type Slash = (); + type Reward = (); + type MaxControllersInDeprecationBatch = (); + type EventListeners = (); + type DisablingStrategy = pallet_staking::UpToLimitDisablingStrategy; + type WeightInfo = pallet_staking::weights::SubstrateWeight; +} + +impl pallet_indexing::pallet::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = pallet_indexing::weights::SubstrateWeight; +} + +impl pallet_prover_db_indexer::pallet::Config for Test { + type RuntimeEvent = RuntimeEvent; +} + +pub type BlockNumber = u64; + +parameter_types! { + pub const Period: BlockNumber = 1; + pub const Offset: BlockNumber = 0; +} + +pub struct TestSessionHandler; +impl pallet_session::SessionHandler for TestSessionHandler { + const KEY_TYPE_IDS: &'static [KeyTypeId] = &[sp_core::crypto::key_types::DUMMY]; + + fn on_new_session( + _changed: bool, + _validators: &[(AccountId, Ks)], + _queued_validators: &[(AccountId, Ks)], + ) { + } + + fn on_disabled(_validator_index: u32) {} + + fn on_genesis_session(_validators: &[(AccountId, Ks)]) {} +} + +impl pallet_session::Config for Test { + type SessionManager = (); + type Keys = sp_runtime::testing::UintAuthorityId; + type ShouldEndSession = pallet_session::PeriodicSessions; + type SessionHandler = TestSessionHandler; type RuntimeEvent = RuntimeEvent; + type ValidatorId = AccountId; + type ValidatorIdOf = pallet_staking::StashOf; + type NextSessionRotation = pallet_session::PeriodicSessions; type WeightInfo = (); } +impl pallet_session::historical::Config for Test { + type FullIdentification = pallet_staking::Exposure; + type FullIdentificationOf = pallet_staking::ExposureOf; +} -#[derive_impl(pallet_balances::config_preludes::TestDefaultConfig)] -impl pallet_balances::Config for Test { - type AccountStore = System; - type Balance = Balance; +sp_runtime::impl_opaque_keys! { + pub struct SessionKeys { + pub foo: sp_runtime::testing::UintAuthorityId, + } +} + +impl pallet_zkpay::Config for Test { type RuntimeEvent = RuntimeEvent; - type ExistentialDeposit = ConstU128<1>; } -impl pallet_prover_db_indexer::Config for Test { +impl pallet_system_tables::Config for Test { type RuntimeEvent = RuntimeEvent; - type TablesPallet = Tables; - // Tests don't exercise the indexing-event extraction path, so we stand - // in with any pallet that's already in the mock runtime. The variant - // index is arbitrary for the same reason. - type IndexingPallet = Tables; - type QuorumReachedVariantIndex = ConstU8<1>; } -/// Build genesis storage for a test externalities. +impl pallet_tables::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +impl pallet_permissions::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +impl pallet_commitments::Config for Test { + const END_ROW_LIMITS_PER_SCHEME: PerCommitmentScheme> = PerCommitmentScheme { + hyper_kzg: 4, + dynamic_dory: 4, + }; +} + pub fn new_test_ext() -> sp_io::TestExternalities { - frame_system::GenesisConfig::::default() + let mut storage = frame_system::GenesisConfig::::default() .build_storage() - .unwrap() - .into() + .unwrap(); + + pallet_commitments::GenesisConfig::::default() + .assimilate_storage(&mut storage) + .unwrap(); + + storage.into() } diff --git a/pallets/prover_db_indexer/src/native_pallet.rs b/pallets/prover_db_indexer/src/native_pallet.rs new file mode 100644 index 00000000..609dc9a2 --- /dev/null +++ b/pallets/prover_db_indexer/src/native_pallet.rs @@ -0,0 +1,16 @@ +//! Type aliases that pin our pallet's instance parameter to +//! `native_api::Api`, so `construct_runtime!` in the runtime can refer +//! to a single-type-parameter `Pallet` form. + +use native_api::Api; + +/// Wrap the pallet type to use the `Api` instance. +pub type Pallet = crate::pallet::Pallet; + +/// Wrap the event type to use the `Api` instance. +pub type Event = crate::pallet::Event; + +pub use crate::pallet::{ + __substrate_call_check, __substrate_event_check, tt_default_parts, tt_default_parts_v2, + tt_error_token, +}; diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 34902511..00d23e6f 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -88,7 +88,6 @@ pallet-system-contracts.workspace = true pallet-prover-db-indexer.workspace = true proof-of-sql-commitment-map.workspace = true sxt-core.workspace = true -lazy_static = { workspace = true } [build-dependencies] substrate-wasm-builder = { optional = true, workspace = true, default-features = true } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b1ef71a2..08ea2d20 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -15,37 +15,22 @@ use alloc::vec; use alloc::vec::Vec; use polkadot_sdk::frame_election_provider_support::{ - generate_solution_type, - onchain, - SequentialPhragmen, + generate_solution_type, onchain, SequentialPhragmen, }; use polkadot_sdk::frame_support::dispatch::DispatchClass; use polkadot_sdk::frame_support::genesis_builder_helper::{build_state, get_preset}; use polkadot_sdk::frame_support::traits::VariantCountOf; pub use polkadot_sdk::frame_support::traits::{ - ConstBool, - ConstU128, - ConstU32, - ConstU64, - ConstU8, - Currency, - KeyOwnerProofSystem, - Randomness, + ConstBool, ConstU128, ConstU32, ConstU64, ConstU8, Currency, KeyOwnerProofSystem, Randomness, StorageInfo, }; pub use polkadot_sdk::frame_support::weights::constants::{ - BlockExecutionWeight, - ExtrinsicBaseWeight, - RocksDbWeight, - WEIGHT_REF_TIME_PER_MILLIS, + BlockExecutionWeight, ExtrinsicBaseWeight, RocksDbWeight, WEIGHT_REF_TIME_PER_MILLIS, }; use polkadot_sdk::frame_support::weights::ConstantMultiplier; pub use polkadot_sdk::frame_support::weights::{IdentityFee, Weight}; pub use polkadot_sdk::frame_support::{ - construct_runtime, - derive_impl, - parameter_types, - StorageValue, + construct_runtime, derive_impl, parameter_types, StorageValue, }; pub use polkadot_sdk::frame_system::Call as SystemCall; use polkadot_sdk::frame_system::EnsureRoot; @@ -63,31 +48,17 @@ use polkadot_sdk::sp_consensus_babe::AuthorityId as BabeId; use polkadot_sdk::sp_core::crypto::KeyTypeId; use polkadot_sdk::sp_core::OpaqueMetadata; use polkadot_sdk::sp_runtime::traits::{ - AccountIdLookup, - BlakeTwo256, - Block as BlockT, - Bounded, - IdentifyAccount, - NumberFor, - OpaqueKeys, - Verify, - Zero, + AccountIdLookup, BlakeTwo256, Block as BlockT, Bounded, IdentifyAccount, NumberFor, OpaqueKeys, + Verify, Zero, }; use polkadot_sdk::sp_runtime::transaction_validity::{ - TransactionPriority, - TransactionSource, - TransactionValidity, + TransactionPriority, TransactionSource, TransactionValidity, }; #[cfg(any(feature = "std", test))] pub use polkadot_sdk::sp_runtime::BuildStorage; use polkadot_sdk::sp_runtime::{ - create_runtime_str, - generic, - impl_opaque_keys, - ApplyExtrinsicResult, - FixedPointNumber, - FixedU128, - Perquintill, + create_runtime_str, generic, impl_opaque_keys, ApplyExtrinsicResult, FixedPointNumber, + FixedU128, Perquintill, }; pub use polkadot_sdk::sp_runtime::{Perbill, Percent, Permill}; use polkadot_sdk::sp_staking::SessionIndex; @@ -95,60 +66,23 @@ use polkadot_sdk::sp_staking::SessionIndex; use polkadot_sdk::sp_version::NativeVersion; use polkadot_sdk::sp_version::RuntimeVersion; use polkadot_sdk::{ - frame_support, - frame_system, - frame_system_rpc_runtime_api, - pallet_authority_discovery, - pallet_authorship, - pallet_babe, - pallet_bags_list, - pallet_balances, - pallet_election_provider_multi_phase, - pallet_grandpa, - pallet_im_online, - pallet_migrations, - pallet_multisig, - pallet_offences, - pallet_session, - pallet_staking, - pallet_staking_runtime_api, - pallet_statement, - pallet_sudo, - pallet_timestamp, - pallet_transaction_payment, - pallet_transaction_payment_rpc_runtime_api, - pallet_utility, - sp_api, - sp_authority_discovery, - sp_block_builder, - sp_consensus_babe, - sp_consensus_grandpa, - sp_core, - sp_genesis_builder, - sp_inherents, - sp_offchain, - sp_runtime, - sp_session, - sp_staking, - sp_statement_store, - sp_transaction_pool, - sp_version, + frame_support, frame_system, frame_system_rpc_runtime_api, pallet_authority_discovery, + pallet_authorship, pallet_babe, pallet_bags_list, pallet_balances, + pallet_election_provider_multi_phase, pallet_grandpa, pallet_im_online, pallet_migrations, + pallet_multisig, pallet_offences, pallet_session, pallet_staking, pallet_staking_runtime_api, + pallet_statement, pallet_sudo, pallet_timestamp, pallet_transaction_payment, + pallet_transaction_payment_rpc_runtime_api, pallet_utility, sp_api, sp_authority_discovery, + sp_block_builder, sp_consensus_babe, sp_consensus_grandpa, sp_core, sp_genesis_builder, + sp_inherents, sp_offchain, sp_runtime, sp_session, sp_staking, sp_statement_store, + sp_transaction_pool, sp_version, }; use proof_of_sql_commitment_map::generic_over_commitment::ConcreteType; use proof_of_sql_commitment_map::PerCommitmentScheme; use sxt_core::system_tables::ClaimedUnstake; pub use { - pallet_attestation, - pallet_commitments, - pallet_indexing, - pallet_keystore, - pallet_permissions, - pallet_prover_db_indexer, - pallet_rewards, - pallet_smartcontracts, - pallet_system_contracts, - pallet_system_tables, - pallet_tables, + pallet_attestation, pallet_commitments, pallet_indexing, pallet_keystore, pallet_permissions, + pallet_prover_db_indexer, pallet_rewards, pallet_smartcontracts, pallet_system_contracts, + pallet_system_tables, pallet_tables, }; /// An index to a block. @@ -892,55 +826,8 @@ impl pallet_rewards::Config for Runtime { type MaxPayoutsPerBlock = ConstU32<3>; } -impl pallet_prover_db_indexer::Config for Runtime { +impl pallet_prover_db_indexer::Config for Runtime { type RuntimeEvent = RuntimeEvent; - type TablesPallet = Tables; - type IndexingPallet = Indexing; - type QuorumReachedVariantIndex = DynamicQuorumReachedIndex; -} - -/// Resolves the variant index of `QuorumReached` in -/// `pallet_indexing::Event` by name via `scale_info`, rather than -/// hard-coding an integer that silently drifts if the enum is ever -/// reordered. Panics on first access with a clear message if the -/// variant goes missing or is renamed — loud failure is strictly better -/// than the prover-db-indexer silently skipping every quorum event forever. -/// -/// The resolved index is cached in a `lazy_static!` so repeated `get()` -/// calls on the hot path (per-event filtering in prover-db-indexer) are a -/// single atomic load rather than a fresh `scale_info::type_info()` -/// traversal. -pub struct DynamicQuorumReachedIndex; - -lazy_static::lazy_static! { - static ref QUORUM_REACHED_VARIANT_INDEX: u8 = find_event_variant_index::< - pallet_indexing::Event, - >("QuorumReached") - .expect( - "pallet_indexing::Event must expose a QuorumReached variant; \ - rename or removal is a breaking change to the prover-db-indexer \ - integration and must be coordinated.", - ); -} - -impl polkadot_sdk::frame_support::traits::Get for DynamicQuorumReachedIndex { - fn get() -> u8 { - *QUORUM_REACHED_VARIANT_INDEX - } -} - -/// Look up an event enum's variant index by name using the `scale_info` -/// metadata FRAME derives on every pallet Event. Returns `None` if the -/// type isn't a variant (non-enum) or the name isn't present. -fn find_event_variant_index(variant_name: &str) -> Option { - match E::type_info().type_def { - scale_info::TypeDef::Variant(ref v) => v - .variants - .iter() - .find(|variant| variant.name == variant_name) - .map(|variant| variant.index), - _ => None, - } } #[cfg(feature = "runtime-benchmarks")] @@ -1052,7 +939,7 @@ mod runtime { #[runtime::pallet_index(110)] pub type ZkPay = pallet_zkpay; #[runtime::pallet_index(111)] - pub type ProverDbIndexer = pallet_prover_db_indexer; + pub type ProverDbIndexer = pallet_prover_db_indexer::native_pallet::Pallet; } /// The address format for describing accounts. diff --git a/runtime/src/tests.rs b/runtime/src/tests.rs index d51fbddd..c7e6d9c8 100644 --- a/runtime/src/tests.rs +++ b/runtime/src/tests.rs @@ -18,18 +18,3 @@ fn era_payout_calculation_works() { let single_era_payout = Balance::from(26557152635181379u128); assert_eq!(to_stakers, single_era_payout); } - -#[test] -fn dynamic_quorum_reached_index_resolves() { - use polkadot_sdk::frame_support::traits::Get; - // If `pallet_indexing::Event::QuorumReached` is ever renamed or removed, - // this test panics at `cargo test` time instead of failing silently in - // the prover-db-indexer at runtime. - let idx = crate::DynamicQuorumReachedIndex::get(); - // The lookup must succeed (get() panics otherwise). We don't assert a - // specific index — reordering the enum is fine, renaming it is not. - assert!( - idx < u8::MAX, - "sanity-check: variant index should be well-defined" - ); -} From 4e7f58eba65d99e09ff07d01a89359f417121a6b Mon Sep 17 00:00:00 2001 From: "rishitesh.mishra@spaceandtime.io" Date: Mon, 27 Apr 2026 19:17:12 +0530 Subject: [PATCH 13/13] refactor(prover-db-indexer): make event extractors pure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: prefer immutability. The two extraction helpers no longer take `&mut BlockIndex` — they return what would be appended (`Vec` for tables since one event can yield N creates, `Option` for indexing since it's at most one). `extract_block_index` flattens them into a single `collect`, and `BlockIndex` is constructed once from the result rather than mutated in a loop. --- pallets/prover_db_indexer/src/lib.rs | 80 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 41 deletions(-) diff --git a/pallets/prover_db_indexer/src/lib.rs b/pallets/prover_db_indexer/src/lib.rs index 39feb77d..8dc78901 100644 --- a/pallets/prover_db_indexer/src/lib.rs +++ b/pallets/prover_db_indexer/src/lib.rs @@ -148,71 +148,69 @@ pub mod pallet { // ═══════════════════════════════════════════════════════════════ fn extract_block_index() -> BlockIndex { - let mut index = BlockIndex::default(); - - for record in polkadot_sdk::frame_system::Pallet::::read_events_no_consensus() { - // `record.event` is the outer `RuntimeEvent`. We try - // converting it back to each typed pallet event via - // `TryInto` impls generated by `construct_runtime!`. - // Cloning is cheap — events are small SCALE enums — and - // saves us writing two extraction passes back-to-back. - Self::try_extract_table_event(record.event.clone(), &mut index); - Self::try_extract_indexing_event(record.event, &mut index); - } - - index + // Each frame-system event may produce zero or more `BlockEvent`s + // (a `pallet_tables::SchemaUpdated` carries N updates, an + // indexing quorum yields one, anything else yields nothing). + // The extractors are pure: they take an event and return what + // would be appended, leaving accumulation to the caller. + let events = polkadot_sdk::frame_system::Pallet::::read_events_no_consensus() + .flat_map(|record| { + let from_tables = Self::try_extract_table_event(record.event.clone()); + let from_indexing = Self::try_extract_indexing_event(record.event); + from_tables.into_iter().chain(from_indexing) + }) + .collect(); + BlockIndex { events } } fn try_extract_table_event( event: ::RuntimeEvent, - index: &mut BlockIndex, - ) { + ) -> Vec { // `event` is the frame-system RuntimeEvent. Bounce through our // Config's RuntimeEvent (same type at runtime, bridged by `IsType`) // so the `TryInto` bound applies. let our_event = <>::RuntimeEvent as From<_>>::from(event); let Ok(table_event): Result, _> = our_event.try_into() else { - return; + return Vec::new(); }; match table_event { - pallet_tables::Event::SchemaUpdated(_who, update_list) => { - for update in update_list.iter() { - index.events.push(BlockEvent::Create(CreateEntry { + pallet_tables::Event::SchemaUpdated(_who, update_list) => update_list + .iter() + .map(|update| { + BlockEvent::Create(CreateEntry { ident: update.ident.clone(), ddl: update.create_statement.to_vec(), - })); - } - } - pallet_tables::Event::TablesCreatedWithCommitments { table_list, .. } => { - for req in table_list.iter() { - index.events.push(BlockEvent::Create(CreateEntry { + }) + }) + .collect(), + pallet_tables::Event::TablesCreatedWithCommitments { table_list, .. } => table_list + .iter() + .map(|req| { + BlockEvent::Create(CreateEntry { ident: req.table_name.clone(), ddl: req.ddl.to_vec(), - })); - } - } + }) + }) + .collect(), pallet_tables::Event::TableDropped(_who, _table_type, ident, _source) => { - index.events.push(BlockEvent::Drop(ident)); + alloc::vec![BlockEvent::Drop(ident)] } - _ => {} + _ => Vec::new(), } } fn try_extract_indexing_event( event: ::RuntimeEvent, - index: &mut BlockIndex, - ) { + ) -> Option { let our_event = <>::RuntimeEvent as From<_>>::from(event); - let Ok(indexing_event): Result, _> = our_event.try_into() - else { - return; + let indexing_event: pallet_indexing::Event = our_event.try_into().ok()?; + let pallet_indexing::Event::QuorumReached { quorum, data } = indexing_event else { + return None; }; - if let pallet_indexing::Event::QuorumReached { quorum, data } = indexing_event { - index.events.push(BlockEvent::Data(DataEntry { - table: quorum.table, - data: data.to_vec(), - })); - } + Some(BlockEvent::Data(DataEntry { + table: quorum.table, + data: data.to_vec(), + })) } // ═══════════════════════════════════════════════════════════════