From c67ff27bc1907a779055bde4154bf2c8f9891f24 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 3 May 2026 18:01:53 +0545 Subject: [PATCH 1/4] feat(ol/rpc): add decoded RPC endpoints for blocks, txs, and accounts Adds four endpoints to OLFullNodeRpc that return decoded JSON instead of raw SSZ bytes: - strata_getBlockBySlot(slot) -> Option - strata_getRecentBlocks(count) -> Vec - strata_getBlockTransactions(slot) -> Vec - strata_listAccounts(block_or_tag) -> Vec New response types in strata-ol-rpc-types map SSZ block/tx/account internals into JSON-friendly views with hex-encoded fields. Exposes a public ledger iterator on TsnlLedgerAccountsTable + OLState so account listing reuses the existing get_toplevel_ol_state provider method without requiring a new one. getBlockLogs is intentionally deferred to a follow-up ticket. Per-block logs are not persisted today (only logs_root is); adding the endpoint requires new storage infrastructure. Refs STR-3097. --- Cargo.lock | 1 + bin/strata/src/rpc/node.rs | 132 +++++++++++++- bin/strata/src/rpc/node_tests.rs | 203 +++++++++++++++++++++ crates/ol/rpc/api/src/lib.rs | 28 +++ crates/ol/rpc/types/Cargo.toml | 1 + crates/ol/rpc/types/src/account_state.rs | 101 +++++++++++ crates/ol/rpc/types/src/block.rs | 147 ++++++++++++++- crates/ol/rpc/types/src/lib.rs | 13 +- crates/ol/rpc/types/src/tx.rs | 222 ++++++++++++++++++++++- crates/ol/state-types/src/ledger.rs | 25 +++ crates/ol/state-types/src/toplevel.rs | 5 + 11 files changed, 868 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15e1cd10af..4efb29f068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15404,6 +15404,7 @@ dependencies = [ "strata-csm-types", "strata-db-types", "strata-identifiers", + "strata-ledger-types", "strata-ol-block-assembly", "strata-ol-chain-types-new", "strata-ol-mempool", diff --git a/bin/strata/src/rpc/node.rs b/bin/strata/src/rpc/node.rs index 48e7c13bf6..9df6478581 100644 --- a/bin/strata/src/rpc/node.rs +++ b/bin/strata/src/rpc/node.rs @@ -15,9 +15,10 @@ use strata_ledger_types::{IAccountState, ISnarkAccountState}; use strata_ol_chain_types_new::{OLBlock, OLTransaction, TransactionPayload}; use strata_ol_rpc_api::{OLClientRpcServer, OLFullNodeRpcServer}; use strata_ol_rpc_types::{ - OLBlockOrTag, OLRpcProvider, RpcAccountBlockSummary, RpcAccountEpochSummary, RpcBlockEntry, - RpcBlockHeaderEntry, RpcCheckpointConfStatus, RpcCheckpointInfo, RpcCheckpointL1Ref, - RpcOLBlockInfo, RpcOLChainStatus, RpcOLTransaction, RpcSnarkAccountState, RpcUpdateInputData, + OLBlockOrTag, OLRpcProvider, RpcAccountBlockSummary, RpcAccountEntry, RpcAccountEpochSummary, + RpcBlockEntry, RpcBlockHeaderEntry, RpcCheckpointConfStatus, RpcCheckpointInfo, + RpcCheckpointL1Ref, RpcOLBlockDetail, RpcOLBlockInfo, RpcOLBlockSummary, RpcOLChainStatus, + RpcOLTransaction, RpcOLTxDetail, RpcSnarkAccountState, RpcUpdateInputData, }; use strata_ol_state_types::OLState; use strata_primitives::{HexBytes, HexBytes32}; @@ -151,6 +152,50 @@ impl OLRpcServer

{ }) } + /// Resolves an [`OLBlockOrTag`] to a concrete [`OLBlockCommitment`]. + /// + /// `Latest`, `Confirmed`, and `Finalized` come from the OL sync status. + /// `OLBlockId` requires fetching the block to read its slot. `Slot` looks + /// up the canonical block at that slot. + async fn resolve_block_or_tag( + &self, + block_or_tag: OLBlockOrTag, + ) -> RpcResult { + Ok(match block_or_tag { + OLBlockOrTag::Latest => { + self.provider + .get_ol_sync_status() + .ok_or_else(|| internal_error("OL sync status not available"))? + .tip + } + OLBlockOrTag::Confirmed => { + // TODO(STR-2420): prev_epoch is not exactly the confirmed epoch. + self.provider + .get_ol_sync_status() + .ok_or_else(|| internal_error("OL sync status not available"))? + .prev_epoch + .to_block_commitment() + } + OLBlockOrTag::Finalized => { + self.provider + .get_ol_sync_status() + .ok_or_else(|| internal_error("OL sync status not available"))? + .finalized_epoch + .to_block_commitment() + } + OLBlockOrTag::OLBlockId(block_id) => { + let block = self.get_block(block_id).await?; + OLBlockCommitment::new(block.header().slot(), block_id) + } + OLBlockOrTag::Slot(slot) => self + .provider + .get_canonical_block_at(slot) + .await + .map_err(db_error)? + .ok_or_else(|| not_found_error(format!("No block found at slot {slot}")))?, + }) + } + /// Walks the canonical chain backwards from `end_slot` to `start_slot`, /// returning blocks in ascending slot order. Each entry carries /// `(slot, blkid, epoch)`; epoch is read off the header during the walk. @@ -894,4 +939,85 @@ impl OLFullNodeRpcServer for OLRpcServer

{ Ok(entries) } + + async fn get_block_by_slot(&self, slot: u64) -> RpcResult> { + let Some(blkid) = self.get_canonical_block_at_height(slot).await? else { + return Ok(None); + }; + let block = self.get_block(blkid).await?; + Ok(Some(RpcOLBlockDetail::from(&block))) + } + + async fn get_recent_blocks(&self, count: u64) -> RpcResult> { + if count == 0 { + return Ok(Vec::new()); + } + if count as usize > self.max_headers_range { + return Err(invalid_params_error(format!( + "count {} exceeds max_headers_range {}", + count, self.max_headers_range + ))); + } + + let tip_slot = self + .provider + .get_ol_sync_status() + .ok_or_else(|| internal_error("OL sync status not available"))? + .tip + .slot(); + + let Some(tip_blkid) = self.get_canonical_block_at_height(tip_slot).await? else { + return Ok(Vec::new()); + }; + + let mut cur_blkid = tip_blkid; + let mut summaries = Vec::with_capacity(count as usize); + for _ in 0..count { + let block = self.get_block(cur_blkid).await?; + let header = block.header(); + summaries.push(RpcOLBlockSummary::from(&block)); + if header.slot() == 0 { + break; + } + cur_blkid = *header.parent_blkid(); + } + summaries.reverse(); + Ok(summaries) + } + + async fn get_block_transactions(&self, slot: u64) -> RpcResult> { + let blkid = self + .get_canonical_block_at_height(slot) + .await? + .ok_or_else(|| not_found_error(format!("No block found at slot {slot}")))?; + let block = self.get_block(blkid).await?; + let txs = block + .body() + .tx_segment() + .map(|seg| seg.txs().iter().map(RpcOLTxDetail::from).collect()) + .unwrap_or_default(); + Ok(txs) + } + + async fn list_accounts( + &self, + block_or_tag: OLBlockOrTag, + ) -> RpcResult> { + let block_commitment = self.resolve_block_or_tag(block_or_tag).await?; + let ol_state = self + .provider + .get_toplevel_ol_state(block_commitment) + .await + .map_err(db_error)? + .ok_or_else(|| { + not_found_error(format!("No OL state found for block {block_commitment}")) + })?; + + let entries = ol_state + .ledger() + .entries() + .map(RpcAccountEntry::from) + .collect(); + Ok(entries) + } } diff --git a/bin/strata/src/rpc/node_tests.rs b/bin/strata/src/rpc/node_tests.rs index f4c5b911f7..3023aef0a0 100644 --- a/bin/strata/src/rpc/node_tests.rs +++ b/bin/strata/src/rpc/node_tests.rs @@ -1992,3 +1992,206 @@ async fn raw_blocks_range_exceeds_max_returns_invalid_params() { assert!(result.is_err()); assert_eq!(result.unwrap_err().code(), INVALID_PARAMS_CODE); } + +// ── get_block_by_slot ── + +#[tokio::test] +async fn get_block_by_slot_returns_decoded_detail() { + let block = make_block(7, 1, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(7, blkid); + + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 1, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block, genesis_ol_state()); + let rpc = make_rpc(provider); + + let detail = rpc + .get_block_by_slot(7) + .await + .expect("rpc call") + .expect("block present"); + assert_eq!(detail.header().slot(), 7); + assert_eq!(detail.header().epoch(), 1); + assert_eq!(detail.header().blkid(), blkid); + assert_eq!(detail.tx_count(), 0); + assert!(detail.l1_update().is_none()); +} + +#[tokio::test] +async fn get_block_by_slot_unknown_returns_none() { + let provider = MockProvider::new().with_sync_status(make_sync_status( + OLBlockCommitment::new(0, null_blkid()), + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )); + let rpc = make_rpc(provider); + + let detail = rpc.get_block_by_slot(42).await.expect("rpc call"); + assert!(detail.is_none()); +} + +// ── get_recent_blocks ── + +#[tokio::test] +async fn get_recent_blocks_walks_backwards_in_order() { + let block0 = make_block(0, 0, null_blkid()); + let blkid0 = block0.header().compute_blkid(); + let block1 = make_block(1, 0, blkid0); + let blkid1 = block1.header().compute_blkid(); + let block2 = make_block(2, 0, blkid1); + let blkid2 = block2.header().compute_blkid(); + + let tip = OLBlockCommitment::new(2, blkid2); + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block0, genesis_ol_state()) + .with_block_and_state(&block1, genesis_ol_state()) + .with_block_and_state(&block2, genesis_ol_state()); + let rpc = make_rpc(provider); + + let summaries = rpc.get_recent_blocks(3).await.expect("recent blocks"); + assert_eq!(summaries.len(), 3); + assert_eq!(summaries[0].slot(), 0); + assert_eq!(summaries[1].slot(), 1); + assert_eq!(summaries[2].slot(), 2); + assert_eq!(summaries[2].blkid(), blkid2); + assert!(summaries.iter().all(|s| s.tx_count() == 0)); +} + +#[tokio::test] +async fn get_recent_blocks_zero_returns_empty() { + let provider = MockProvider::new().with_sync_status(make_sync_status( + OLBlockCommitment::new(5, null_blkid()), + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )); + let rpc = make_rpc(provider); + + let summaries = rpc.get_recent_blocks(0).await.expect("rpc call"); + assert!(summaries.is_empty()); +} + +#[tokio::test] +async fn get_recent_blocks_caps_at_genesis_when_count_exceeds_tip() { + let block0 = make_block(0, 0, null_blkid()); + let blkid0 = block0.header().compute_blkid(); + let block1 = make_block(1, 0, blkid0); + let blkid1 = block1.header().compute_blkid(); + + let tip = OLBlockCommitment::new(1, blkid1); + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block0, genesis_ol_state()) + .with_block_and_state(&block1, genesis_ol_state()); + let rpc = make_rpc(provider); + + let summaries = rpc.get_recent_blocks(10).await.expect("rpc call"); + assert_eq!(summaries.len(), 2); + assert_eq!(summaries[0].slot(), 0); + assert_eq!(summaries[1].slot(), 1); +} + +// ── get_block_transactions ── + +#[tokio::test] +async fn get_block_transactions_empty_block_returns_empty() { + let block = make_block(3, 0, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(3, blkid); + + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block, genesis_ol_state()); + let rpc = make_rpc(provider); + + let txs = rpc.get_block_transactions(3).await.expect("txs"); + assert!(txs.is_empty()); +} + +#[tokio::test] +async fn get_block_transactions_unknown_slot_errors() { + let provider = MockProvider::new().with_sync_status(make_sync_status( + OLBlockCommitment::new(0, null_blkid()), + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )); + let rpc = make_rpc(provider); + + let result = rpc.get_block_transactions(99).await; + assert!(result.is_err()); +} + +// ── list_accounts ── + +#[tokio::test] +async fn list_accounts_returns_ledger_entries() { + let acct = AccountId::from([0x11; 32]); + let block = make_block(4, 0, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(4, blkid); + + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state( + &block, + ol_state_with_snark_account(acct, 4, 7, DEFAULT_NEXT_INBOX_MSG_IDX), + ); + let rpc = make_rpc(provider); + + let entries = rpc + .list_accounts(OLBlockOrTag::Slot(4)) + .await + .expect("list accounts"); + let our_entry = entries + .iter() + .find(|e| e.id().0 == *acct.inner()) + .expect("account present in ledger"); + assert_eq!(our_entry.account_type(), RpcAccountType::Snark); + let snark = our_entry.snark().expect("snark summary"); + assert_eq!(snark.seq_no(), 7); +} diff --git a/crates/ol/rpc/api/src/lib.rs b/crates/ol/rpc/api/src/lib.rs index b1d67f1005..e4028005a1 100644 --- a/crates/ol/rpc/api/src/lib.rs +++ b/crates/ol/rpc/api/src/lib.rs @@ -99,6 +99,34 @@ pub trait OLFullNodeRpc { start_height: u64, end_height: u64, ) -> RpcResult>; + + /// Get the canonical OL block at the given slot, fully decoded. + /// + /// Returns `None` when no block exists at the slot. + #[method(name = "getBlockBySlot")] + async fn get_block_by_slot(&self, slot: u64) -> RpcResult>; + + /// Get the most recent `count` canonical OL blocks as lightweight summaries. + /// + /// Walks backwards from the chain tip via parent links and returns the + /// blocks in ascending slot order. + #[method(name = "getRecentBlocks")] + async fn get_recent_blocks(&self, count: u64) -> RpcResult>; + + /// Get all transactions in the canonical OL block at the given slot. + #[method(name = "getBlockTransactions")] + async fn get_block_transactions(&self, slot: u64) -> RpcResult>; + + /// List all accounts on the ledger at the given block. + /// + /// `block_or_tag` accepts the same forms as `getSnarkAccountState`: + /// `"latest"`, `"confirmed"`, `"finalized"`, a slot number, or a block ID + /// (`0x...`). + #[method(name = "listAccounts")] + async fn list_accounts( + &self, + block_or_tag: OLBlockOrTag, + ) -> RpcResult>; } /// OL RPC methods served by sequencer node for sequencer signer. diff --git a/crates/ol/rpc/types/Cargo.toml b/crates/ol/rpc/types/Cargo.toml index 1716f89dd8..2812815fc7 100644 --- a/crates/ol/rpc/types/Cargo.toml +++ b/crates/ol/rpc/types/Cargo.toml @@ -14,6 +14,7 @@ strata-checkpoint-types.workspace = true strata-csm-types.workspace = true strata-db-types.workspace = true strata-identifiers.workspace = true +strata-ledger-types.workspace = true strata-ol-block-assembly.workspace = true strata-ol-chain-types-new.workspace = true strata-ol-mempool.workspace = true diff --git a/crates/ol/rpc/types/src/account_state.rs b/crates/ol/rpc/types/src/account_state.rs index 4a4884cbd7..5925ad57a5 100644 --- a/crates/ol/rpc/types/src/account_state.rs +++ b/crates/ol/rpc/types/src/account_state.rs @@ -1,4 +1,6 @@ use serde::{Deserialize, Serialize}; +use strata_ledger_types::{IAccountState, ISnarkAccountState}; +use strata_ol_state_types::TsnlAccountEntry; use strata_primitives::{HexBytes, HexBytes32}; use strata_snark_acct_types::SnarkAccountState; @@ -58,3 +60,102 @@ impl From for RpcSnarkAccountState { } } } + +/// Account list entry returned by `strata_listAccounts`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcAccountEntry { + /// Account ID. + id: HexBytes32, + /// Serial assigned at creation. + serial: u32, + /// Balance in sats. + balance_sats: u64, + /// Account type: "empty" or "snark". + account_type: RpcAccountType, + /// Snark-specific summary, present only when `account_type == "snark"`. + snark: Option, +} + +impl RpcAccountEntry { + pub fn id(&self) -> &HexBytes32 { + &self.id + } + + pub fn serial(&self) -> u32 { + self.serial + } + + pub fn balance_sats(&self) -> u64 { + self.balance_sats + } + + pub fn account_type(&self) -> RpcAccountType { + self.account_type + } + + pub fn snark(&self) -> Option<&RpcAccountSnarkSummary> { + self.snark.as_ref() + } +} + +impl From<&TsnlAccountEntry> for RpcAccountEntry { + fn from(entry: &TsnlAccountEntry) -> Self { + let state = entry.state(); + let snark_summary = state.as_snark_account().ok().map(|snark| { + let inner_state = snark.inner_state_root(); + RpcAccountSnarkSummary { + seq_no: *snark.seqno().inner(), + inner_state_root: HexBytes32::from(inner_state.0), + next_inbox_msg_idx: snark.next_inbox_msg_idx(), + } + }); + let account_type = if snark_summary.is_some() { + RpcAccountType::Snark + } else { + RpcAccountType::Empty + }; + Self { + id: HexBytes32::from(<[u8; 32]>::from(entry.id())), + serial: *state.serial().inner(), + balance_sats: state.balance().to_sat(), + account_type, + snark: snark_summary, + } + } +} + +/// Account type discriminator for [`RpcAccountEntry`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum RpcAccountType { + Empty, + Snark, +} + +/// Snark-account summary fields surfaced in account listings. +/// +/// Distinct from [`RpcSnarkAccountState`] in that it omits the update verification +/// key, which is not available from the runtime account state. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcAccountSnarkSummary { + seq_no: u64, + inner_state_root: HexBytes32, + next_inbox_msg_idx: u64, +} + +impl RpcAccountSnarkSummary { + pub fn seq_no(&self) -> u64 { + self.seq_no + } + + pub fn inner_state_root(&self) -> &HexBytes32 { + &self.inner_state_root + } + + pub fn next_inbox_msg_idx(&self) -> u64 { + self.next_inbox_msg_idx + } +} diff --git a/crates/ol/rpc/types/src/block.rs b/crates/ol/rpc/types/src/block.rs index 9b851bf82a..f6d904b392 100644 --- a/crates/ol/rpc/types/src/block.rs +++ b/crates/ol/rpc/types/src/block.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use ssz::Encode; use strata_identifiers::{Epoch, Slot}; -use strata_ol_chain_types_new::OLBlock; -use strata_primitives::{HexBytes, HexBytes32, OLBlockId}; +use strata_ol_chain_types_new::{OLBlock, OLL1Update}; +use strata_primitives::{HexBytes, HexBytes32, HexBytes64, OLBlockId}; /// Rpc version of OL block entry in a slot range. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -112,3 +112,146 @@ impl From<&OLBlock> for RpcBlockHeaderEntry { } } } + +/// Lightweight summary of an OL block for list views. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcOLBlockSummary { + slot: Slot, + epoch: Epoch, + blkid: OLBlockId, + timestamp: u64, + tx_count: u32, + is_terminal: bool, +} + +impl RpcOLBlockSummary { + pub fn slot(&self) -> Slot { + self.slot + } + + pub fn epoch(&self) -> Epoch { + self.epoch + } + + pub fn blkid(&self) -> OLBlockId { + self.blkid + } + + pub fn timestamp(&self) -> u64 { + self.timestamp + } + + pub fn tx_count(&self) -> u32 { + self.tx_count + } + + pub fn is_terminal(&self) -> bool { + self.is_terminal + } +} + +impl From<&OLBlock> for RpcOLBlockSummary { + fn from(block: &OLBlock) -> Self { + let header = block.header(); + let tx_count = block + .body() + .tx_segment() + .map(|seg| seg.txs().len() as u32) + .unwrap_or(0); + Self { + slot: header.slot(), + epoch: header.epoch(), + blkid: header.compute_blkid(), + timestamp: header.timestamp(), + tx_count, + is_terminal: header.is_terminal(), + } + } +} + +/// Detailed view of an OL block returned by `getBlockBySlot`. +/// +/// Composes [`RpcBlockHeaderEntry`] with body and credential fields decoded +/// from the SSZ block. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcOLBlockDetail { + /// Decoded header fields. + header: RpcBlockHeaderEntry, + /// Schnorr signature over the header, if present. + signature: Option, + /// Number of transactions in the block. + tx_count: u32, + /// L1 update summary, present only on terminal blocks. + l1_update: Option, +} + +impl RpcOLBlockDetail { + pub fn header(&self) -> &RpcBlockHeaderEntry { + &self.header + } + + pub fn signature(&self) -> Option<&HexBytes64> { + self.signature.as_ref() + } + + pub fn tx_count(&self) -> u32 { + self.tx_count + } + + pub fn l1_update(&self) -> Option<&RpcOLL1UpdateSummary> { + self.l1_update.as_ref() + } +} + +impl From<&OLBlock> for RpcOLBlockDetail { + fn from(block: &OLBlock) -> Self { + let header = RpcBlockHeaderEntry::from(block); + let signature = block + .signed_header() + .signature() + .map(|sig| HexBytes64::from(sig.0)); + let body = block.body(); + let tx_count = body + .tx_segment() + .map(|seg| seg.txs().len() as u32) + .unwrap_or(0); + let l1_update = body.l1_update().map(RpcOLL1UpdateSummary::from); + Self { + header, + signature, + tx_count, + l1_update, + } + } +} + +/// Summary of an OL L1 update segment included in a terminal block. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcOLL1UpdateSummary { + /// State root captured before the seal was applied. + preseal_state_root: HexBytes32, + /// Number of L1 manifests included in the seal. + manifest_count: u32, +} + +impl RpcOLL1UpdateSummary { + pub fn preseal_state_root(&self) -> &HexBytes32 { + &self.preseal_state_root + } + + pub fn manifest_count(&self) -> u32 { + self.manifest_count + } +} + +impl From<&OLL1Update> for RpcOLL1UpdateSummary { + fn from(update: &OLL1Update) -> Self { + Self { + preseal_state_root: HexBytes32::from(update.preseal_state_root().0), + manifest_count: update.manifest_cont().manifests().len() as u32, + } + } +} diff --git a/crates/ol/rpc/types/src/lib.rs b/crates/ol/rpc/types/src/lib.rs index fb3f8ef335..01d74278b8 100644 --- a/crates/ol/rpc/types/src/lib.rs +++ b/crates/ol/rpc/types/src/lib.rs @@ -14,11 +14,15 @@ mod provider; mod snark_acct_update; mod tx; -pub use account_state::RpcSnarkAccountState; +pub use account_state::{ + RpcAccountEntry, RpcAccountSnarkSummary, RpcAccountType, RpcSnarkAccountState, +}; pub use account_summary::{ RpcAccountBlockSummary, RpcAccountEpochSummary, RpcMessageEntry, RpcUpdateInputData, }; -pub use block::{RpcBlockEntry, RpcBlockHeaderEntry}; +pub use block::{ + RpcBlockEntry, RpcBlockHeaderEntry, RpcOLBlockDetail, RpcOLBlockSummary, RpcOLL1UpdateSummary, +}; pub use blocktag::OLBlockOrTag; pub use chain_status::{RpcOLBlockInfo, RpcOLChainStatus}; pub use checkpoint::{RpcCheckpointConfStatus, RpcCheckpointInfo, RpcCheckpointL1Ref}; @@ -26,6 +30,7 @@ pub use duty::*; pub use provider::OLRpcProvider; pub use snark_acct_update::RpcSnarkAccountUpdate; pub use tx::{ - RpcGenericAccountMessage, RpcOLTransaction, RpcTransactionPayload, RpcTxConstraints, - RpcTxConversionError, + RpcGenericAccountMessage, RpcOLTransaction, RpcOLTxDetail, RpcSauTxSummary, + RpcSentMessageEffect, RpcSentTransfer, RpcTransactionPayload, RpcTxConstraints, + RpcTxConversionError, RpcTxEffectsView, }; diff --git a/crates/ol/rpc/types/src/tx.rs b/crates/ol/rpc/types/src/tx.rs index ae70ca5398..612bb069c3 100644 --- a/crates/ol/rpc/types/src/tx.rs +++ b/crates/ol/rpc/types/src/tx.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use ssz::Decode; -use strata_acct_types::AccountId; +use strata_acct_types::{AccountId, SentMessage, SentTransfer, TxEffects}; +use strata_identifiers::OLTxId; use strata_ol_chain_types_new::{ ClaimList, OLTransaction, OLTransactionData, ProofSatisfierList, SauTxLedgerRefs, SauTxOperationData, SauTxPayload, SauTxProofState, SauTxUpdateData, TransactionPayload, @@ -13,6 +14,11 @@ use strata_snark_acct_types::{SnarkAccountUpdate, UpdateOperationData}; use crate::RpcSnarkAccountUpdate; +/// Type discriminator for GAM transactions in [`RpcOLTxDetail::type_id`]. +const TX_TYPE_ID_GAM: u16 = 0; +/// Type discriminator for SAU transactions in [`RpcOLTxDetail::type_id`]. +const TX_TYPE_ID_SAU: u16 = 1; + /// OL transaction for submission (excludes accumulator proofs). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] @@ -152,6 +158,220 @@ pub enum RpcTxConversionError { TooManyAsmHistoryClaims, } +/// Decoded view of a transaction included in a block. +/// +/// Returned by `strata_getBlockTransactions`. Carries the computed txid, the +/// payload type, the target account, constraints, and a summary of effects. +/// SAU-specific fields are populated when `type_id == 1`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcOLTxDetail { + /// Computed transaction ID (tree hash of `OLTransactionData`). + txid: OLTxId, + /// Numeric type discriminator: 0 = GAM, 1 = SAU. + type_id: u16, + /// Human-readable type name. + type_name: String, + /// Target account. + target: HexBytes32, + /// Inclusion constraints. + constraints: RpcTxConstraints, + /// Effects produced when this transaction is applied. + effects: RpcTxEffectsView, + /// SAU-specific fields, present only when `type_id == 1`. + sau: Option, +} + +impl RpcOLTxDetail { + pub fn txid(&self) -> OLTxId { + self.txid + } + + pub fn type_id(&self) -> u16 { + self.type_id + } + + pub fn type_name(&self) -> &str { + &self.type_name + } + + pub fn target(&self) -> &HexBytes32 { + &self.target + } + + pub fn constraints(&self) -> &RpcTxConstraints { + &self.constraints + } + + pub fn effects(&self) -> &RpcTxEffectsView { + &self.effects + } + + pub fn sau(&self) -> Option<&RpcSauTxSummary> { + self.sau.as_ref() + } +} + +impl From<&OLTransaction> for RpcOLTxDetail { + fn from(tx: &OLTransaction) -> Self { + let txid = tx.compute_txid(); + let data = tx.data(); + let (type_id, type_name, sau) = match data.payload() { + TransactionPayload::GenericAccountMessage(_) => ( + TX_TYPE_ID_GAM, + "generic_account_message".to_string(), + None, + ), + TransactionPayload::SnarkAccountUpdate(sau_payload) => ( + TX_TYPE_ID_SAU, + "snark_account_update".to_string(), + Some(RpcSauTxSummary::from(sau_payload)), + ), + }; + let target = tx + .target() + .map(|a| HexBytes32::from(<[u8; 32]>::from(a))) + .unwrap_or_else(|| HexBytes32::from([0u8; 32])); + let constraints = RpcTxConstraints::from(data.constraints().clone()); + let effects = RpcTxEffectsView::from(data.effects()); + Self { + txid, + type_id, + type_name, + target, + constraints, + effects, + sau, + } + } +} + +/// Summary of transfers and messages produced by a transaction. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcTxEffectsView { + transfers: Vec, + messages: Vec, +} + +impl RpcTxEffectsView { + pub fn transfers(&self) -> &[RpcSentTransfer] { + &self.transfers + } + + pub fn messages(&self) -> &[RpcSentMessageEffect] { + &self.messages + } +} + +impl From<&TxEffects> for RpcTxEffectsView { + fn from(effects: &TxEffects) -> Self { + Self { + transfers: effects.transfers_iter().map(RpcSentTransfer::from).collect(), + messages: effects + .messages_iter() + .map(RpcSentMessageEffect::from) + .collect(), + } + } +} + +/// Transfer effect: value sent to a destination account. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcSentTransfer { + dest: HexBytes32, + value_sats: u64, +} + +impl RpcSentTransfer { + pub fn dest(&self) -> &HexBytes32 { + &self.dest + } + + pub fn value_sats(&self) -> u64 { + self.value_sats + } +} + +impl From<&SentTransfer> for RpcSentTransfer { + fn from(xfr: &SentTransfer) -> Self { + Self { + dest: HexBytes32::from(<[u8; 32]>::from(xfr.dest())), + value_sats: xfr.value().to_sat(), + } + } +} + +/// Message effect: payload sent to a destination account with optional value. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcSentMessageEffect { + dest: HexBytes32, + value_sats: u64, + data: HexBytes, +} + +impl RpcSentMessageEffect { + pub fn dest(&self) -> &HexBytes32 { + &self.dest + } + + pub fn value_sats(&self) -> u64 { + self.value_sats + } + + pub fn data(&self) -> &HexBytes { + &self.data + } +} + +impl From<&SentMessage> for RpcSentMessageEffect { + fn from(msg: &SentMessage) -> Self { + let payload = msg.payload(); + Self { + dest: HexBytes32::from(<[u8; 32]>::from(msg.dest())), + value_sats: payload.value().to_sat(), + data: HexBytes(payload.data().to_vec()), + } + } +} + +/// SAU-specific summary fields extracted from a snark account update payload. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcSauTxSummary { + seq_no: u64, + new_next_msg_idx: u64, + inner_state_root: HexBytes32, +} + +impl RpcSauTxSummary { + pub fn seq_no(&self) -> u64 { + self.seq_no + } + + pub fn new_next_msg_idx(&self) -> u64 { + self.new_next_msg_idx + } + + pub fn inner_state_root(&self) -> &HexBytes32 { + &self.inner_state_root + } +} + +impl From<&SauTxPayload> for RpcSauTxSummary { + fn from(payload: &SauTxPayload) -> Self { + let update = payload.operation().update(); + let proof_state = update.proof_state(); + Self { + seq_no: update.seq_no(), + new_next_msg_idx: proof_state.new_next_msg_idx(), + inner_state_root: HexBytes32::from(proof_state.inner_state_root().0), + } + } +} + impl TryFrom for OLTransaction { type Error = RpcTxConversionError; diff --git a/crates/ol/state-types/src/ledger.rs b/crates/ol/state-types/src/ledger.rs index 5497024da8..a77a85c859 100644 --- a/crates/ol/state-types/src/ledger.rs +++ b/crates/ol/state-types/src/ledger.rs @@ -40,6 +40,21 @@ impl TsnlLedgerAccountsTable { self.get_acct_entry_mut(id).map(|e| &mut e.state) } + /// Returns an iterator over all account entries in ascending ID order. + pub fn entries(&self) -> impl Iterator { + self.accounts.iter() + } + + /// Returns the number of accounts in the ledger. + pub fn len(&self) -> usize { + self.accounts.len() + } + + /// Returns whether the ledger has no accounts. + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + /// Creates a new account. /// /// This does not check serial uniqueness/ordering. @@ -96,6 +111,16 @@ impl TsnlAccountEntry { fn new(id: AccountId, state: OLAccountState) -> Self { Self { id, state } } + + /// Returns the account ID. + pub fn id(&self) -> AccountId { + self.id + } + + /// Returns the account state. + pub fn state(&self) -> &OLAccountState { + &self.state + } } #[cfg(test)] diff --git a/crates/ol/state-types/src/toplevel.rs b/crates/ol/state-types/src/toplevel.rs index 0df14ed46d..d1e113d823 100644 --- a/crates/ol/state-types/src/toplevel.rs +++ b/crates/ol/state-types/src/toplevel.rs @@ -70,6 +70,11 @@ impl OLState { &self.epoch } + /// Returns the ledger accounts table. + pub fn ledger(&self) -> &TsnlLedgerAccountsTable { + &self.ledger + } + /// Checks that a batch can be applied safely. /// /// This checks: From eaedfb2ee382967d9e7904fa4c919f656b5eab19 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 3 May 2026 18:40:21 +0545 Subject: [PATCH 2/4] review: bound listAccounts pagination and fix confirmed-tag resolution Addresses two findings from a codex code review: 1. listAccounts was unbounded: a large ledger could return a massive single response. Add (start, count) pagination with a server-side cap of max_headers_range, and wrap the response in RpcAccountListPage so we can extend it with new fields without breaking the wire format. 2. resolve_block_or_tag for the Confirmed tag was using prev_epoch, which can lag behind confirmed_epoch when epochs complete locally before being observed on L1. Use confirmed_epoch directly from ChainSyncStatus. (The matching bug in get_snark_account_state still has a TODO referencing STR-2420; out of scope for this PR.) Adds two tests for the new pagination bounds. Refs STR-3097. --- bin/strata/src/rpc/node.rs | 38 ++++++++++++---- bin/strata/src/rpc/node_tests.rs | 58 ++++++++++++++++++++++-- crates/ol/rpc/api/src/lib.rs | 10 +++- crates/ol/rpc/types/src/account_state.rs | 38 ++++++++++++++++ crates/ol/rpc/types/src/lib.rs | 3 +- 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/bin/strata/src/rpc/node.rs b/bin/strata/src/rpc/node.rs index 9df6478581..7c93d20bb4 100644 --- a/bin/strata/src/rpc/node.rs +++ b/bin/strata/src/rpc/node.rs @@ -16,9 +16,9 @@ use strata_ol_chain_types_new::{OLBlock, OLTransaction, TransactionPayload}; use strata_ol_rpc_api::{OLClientRpcServer, OLFullNodeRpcServer}; use strata_ol_rpc_types::{ OLBlockOrTag, OLRpcProvider, RpcAccountBlockSummary, RpcAccountEntry, RpcAccountEpochSummary, - RpcBlockEntry, RpcBlockHeaderEntry, RpcCheckpointConfStatus, RpcCheckpointInfo, - RpcCheckpointL1Ref, RpcOLBlockDetail, RpcOLBlockInfo, RpcOLBlockSummary, RpcOLChainStatus, - RpcOLTransaction, RpcOLTxDetail, RpcSnarkAccountState, RpcUpdateInputData, + RpcAccountListPage, RpcBlockEntry, RpcBlockHeaderEntry, RpcCheckpointConfStatus, + RpcCheckpointInfo, RpcCheckpointL1Ref, RpcOLBlockDetail, RpcOLBlockInfo, RpcOLBlockSummary, + RpcOLChainStatus, RpcOLTransaction, RpcOLTxDetail, RpcSnarkAccountState, RpcUpdateInputData, }; use strata_ol_state_types::OLState; use strata_primitives::{HexBytes, HexBytes32}; @@ -169,11 +169,10 @@ impl OLRpcServer

{ .tip } OLBlockOrTag::Confirmed => { - // TODO(STR-2420): prev_epoch is not exactly the confirmed epoch. self.provider .get_ol_sync_status() .ok_or_else(|| internal_error("OL sync status not available"))? - .prev_epoch + .confirmed_epoch .to_block_commitment() } OLBlockOrTag::Finalized => { @@ -1002,7 +1001,19 @@ impl OLFullNodeRpcServer for OLRpcServer

{ async fn list_accounts( &self, block_or_tag: OLBlockOrTag, - ) -> RpcResult> { + start: u64, + count: u64, + ) -> RpcResult { + if count == 0 { + return Ok(RpcAccountListPage::new(Vec::new(), 0, None)); + } + if count as usize > self.max_headers_range { + return Err(invalid_params_error(format!( + "count {} exceeds max_headers_range {}", + count, self.max_headers_range + ))); + } + let block_commitment = self.resolve_block_or_tag(block_or_tag).await?; let ol_state = self .provider @@ -1013,11 +1024,20 @@ impl OLFullNodeRpcServer for OLRpcServer

{ not_found_error(format!("No OL state found for block {block_commitment}")) })?; - let entries = ol_state - .ledger() + let ledger = ol_state.ledger(); + let total = ledger.len() as u64; + let entries: Vec<_> = ledger .entries() + .skip(start as usize) + .take(count as usize) .map(RpcAccountEntry::from) .collect(); - Ok(entries) + let consumed = start.saturating_add(entries.len() as u64); + let next_offset = if consumed < total && (entries.len() as u64) == count { + Some(consumed) + } else { + None + }; + Ok(RpcAccountListPage::new(entries, total, next_offset)) } } diff --git a/bin/strata/src/rpc/node_tests.rs b/bin/strata/src/rpc/node_tests.rs index 3023aef0a0..dc8057c708 100644 --- a/bin/strata/src/rpc/node_tests.rs +++ b/bin/strata/src/rpc/node_tests.rs @@ -2183,15 +2183,67 @@ async fn list_accounts_returns_ledger_entries() { ); let rpc = make_rpc(provider); - let entries = rpc - .list_accounts(OLBlockOrTag::Slot(4)) + let page = rpc + .list_accounts(OLBlockOrTag::Slot(4), 0, 100) .await .expect("list accounts"); - let our_entry = entries + let our_entry = page + .entries() .iter() .find(|e| e.id().0 == *acct.inner()) .expect("account present in ledger"); assert_eq!(our_entry.account_type(), RpcAccountType::Snark); let snark = our_entry.snark().expect("snark summary"); assert_eq!(snark.seq_no(), 7); + assert_eq!(page.total(), page.entries().len() as u64); + assert!(page.next_offset().is_none()); +} + +#[tokio::test] +async fn list_accounts_count_exceeds_max_returns_invalid_params() { + let block = make_block(4, 0, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(4, blkid); + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block, genesis_ol_state()); + let rpc = make_rpc(provider); + + let result = rpc + .list_accounts(OLBlockOrTag::Slot(4), 0, (TEST_MAX_HEADERS_RANGE as u64) + 1) + .await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err().code(), INVALID_PARAMS_CODE); +} + +#[tokio::test] +async fn list_accounts_zero_count_returns_empty_page() { + let block = make_block(4, 0, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(4, blkid); + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block, genesis_ol_state()); + let rpc = make_rpc(provider); + + let page = rpc + .list_accounts(OLBlockOrTag::Slot(4), 0, 0) + .await + .expect("list accounts"); + assert!(page.entries().is_empty()); + assert!(page.next_offset().is_none()); } diff --git a/crates/ol/rpc/api/src/lib.rs b/crates/ol/rpc/api/src/lib.rs index e4028005a1..6e6bc2ec10 100644 --- a/crates/ol/rpc/api/src/lib.rs +++ b/crates/ol/rpc/api/src/lib.rs @@ -117,16 +117,22 @@ pub trait OLFullNodeRpc { #[method(name = "getBlockTransactions")] async fn get_block_transactions(&self, slot: u64) -> RpcResult>; - /// List all accounts on the ledger at the given block. + /// List accounts on the ledger at the given block. /// /// `block_or_tag` accepts the same forms as `getSnarkAccountState`: /// `"latest"`, `"confirmed"`, `"finalized"`, a slot number, or a block ID /// (`0x...`). + /// + /// Results are paginated. `start` is the offset into the sorted ledger + /// (ascending account id), `count` caps the page size. The server enforces + /// `count <= max_headers_range`. #[method(name = "listAccounts")] async fn list_accounts( &self, block_or_tag: OLBlockOrTag, - ) -> RpcResult>; + start: u64, + count: u64, + ) -> RpcResult; } /// OL RPC methods served by sequencer node for sequencer signer. diff --git a/crates/ol/rpc/types/src/account_state.rs b/crates/ol/rpc/types/src/account_state.rs index 5925ad57a5..b5c3aaef79 100644 --- a/crates/ol/rpc/types/src/account_state.rs +++ b/crates/ol/rpc/types/src/account_state.rs @@ -159,3 +159,41 @@ impl RpcAccountSnarkSummary { self.next_inbox_msg_idx } } + +/// Paginated response for `strata_listAccounts`. +/// +/// Wraps a slice of accounts plus pagination metadata so callers can +/// iterate the ledger without forcing the server to materialize every +/// entry in a single response. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +pub struct RpcAccountListPage { + /// Accounts in the requested page, in ascending account-id order. + entries: Vec, + /// Total number of accounts on the ledger at the queried block. + total: u64, + /// Offset to pass for the next page, or `None` if this is the last page. + next_offset: Option, +} + +impl RpcAccountListPage { + pub fn new(entries: Vec, total: u64, next_offset: Option) -> Self { + Self { + entries, + total, + next_offset, + } + } + + pub fn entries(&self) -> &[RpcAccountEntry] { + &self.entries + } + + pub fn total(&self) -> u64 { + self.total + } + + pub fn next_offset(&self) -> Option { + self.next_offset + } +} diff --git a/crates/ol/rpc/types/src/lib.rs b/crates/ol/rpc/types/src/lib.rs index 01d74278b8..1b55a4e329 100644 --- a/crates/ol/rpc/types/src/lib.rs +++ b/crates/ol/rpc/types/src/lib.rs @@ -15,7 +15,8 @@ mod snark_acct_update; mod tx; pub use account_state::{ - RpcAccountEntry, RpcAccountSnarkSummary, RpcAccountType, RpcSnarkAccountState, + RpcAccountEntry, RpcAccountListPage, RpcAccountSnarkSummary, RpcAccountType, + RpcSnarkAccountState, }; pub use account_summary::{ RpcAccountBlockSummary, RpcAccountEpochSummary, RpcMessageEntry, RpcUpdateInputData, From 88e40112d52470cafbfe7b5b625f43036b398ce5 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 3 May 2026 19:30:42 +0545 Subject: [PATCH 3/4] review(self): walk recent-blocks from sync-status tip + cover edge cases Three follow-up items from a self-review pass: 1. get_recent_blocks now walks from sync_status.tip.blkid directly instead of re-resolving the canonical block at tip_slot. Saves a DB call and reads a chain consistent with the tip we observed; if a reorg races the read, the canonical lookup could otherwise disagree with the sync_status snapshot. 2. New regression test for the Confirmed-tag fix from the prior commit. Sets up confirmed_epoch and prev_epoch at different blocks with different ledger contents and asserts listAccounts("confirmed") resolves to the confirmed_epoch (not prev_epoch). 3. New pagination test that walks listAccounts across three offsets (start=0, start=2, start=10) to exercise total/next_offset bookkeeping beyond the first page. Refs STR-3097. --- bin/strata/src/rpc/node.rs | 13 ++-- bin/strata/src/rpc/node_tests.rs | 113 +++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 7 deletions(-) diff --git a/bin/strata/src/rpc/node.rs b/bin/strata/src/rpc/node.rs index 7c93d20bb4..302cd12efc 100644 --- a/bin/strata/src/rpc/node.rs +++ b/bin/strata/src/rpc/node.rs @@ -958,18 +958,17 @@ impl OLFullNodeRpcServer for OLRpcServer

{ ))); } - let tip_slot = self + // Walk parents from the sync-status tip directly so we read a consistent + // chain anchored to the tip we observed (rather than re-resolving the + // canonical block at the tip slot, which costs an extra DB hit and could + // disagree with the snapshot if a reorg races us). + let mut cur_blkid = *self .provider .get_ol_sync_status() .ok_or_else(|| internal_error("OL sync status not available"))? .tip - .slot(); + .blkid(); - let Some(tip_blkid) = self.get_canonical_block_at_height(tip_slot).await? else { - return Ok(Vec::new()); - }; - - let mut cur_blkid = tip_blkid; let mut summaries = Vec::with_capacity(count as usize); for _ in 0..count { let block = self.get_block(cur_blkid).await?; diff --git a/bin/strata/src/rpc/node_tests.rs b/bin/strata/src/rpc/node_tests.rs index dc8057c708..bbfcfe551d 100644 --- a/bin/strata/src/rpc/node_tests.rs +++ b/bin/strata/src/rpc/node_tests.rs @@ -410,6 +410,17 @@ fn ol_state_with_empty_account(account_id: AccountId, slot: u64) -> OLState { state.into_inner() } +fn ol_state_with_n_empty_accounts(account_ids: &[AccountId], slot: u64) -> OLState { + let base = genesis_ol_state(); + let mut state = MemoryStateBaseLayer::new(base); + state.set_cur_slot(slot); + for id in account_ids { + let new_acct = NewAccountData::new(BitcoinAmount::from(0), NewAccountTypeState::Empty); + state.create_new_account(*id, new_acct).unwrap(); + } + state.into_inner() +} + const TEST_GENESIS_L1_HEIGHT: L1Height = 0; const TEST_MAX_HEADERS_RANGE: usize = 5000; @@ -2247,3 +2258,105 @@ async fn list_accounts_zero_count_returns_empty_page() { assert!(page.entries().is_empty()); assert!(page.next_offset().is_none()); } + +#[tokio::test] +async fn list_accounts_paginates_with_start_and_count() { + // Three sequential account ids so iteration order is well-defined. + let acct_a = test_account_id(1); + let acct_b = test_account_id(2); + let acct_c = test_account_id(3); + + let block = make_block(4, 0, null_blkid()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(4, blkid); + + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 0, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state( + &block, + ol_state_with_n_empty_accounts(&[acct_a, acct_b, acct_c], 4), + ); + let rpc = make_rpc(provider); + + // First page: count=2 from offset 0. Expect 2 entries, more available. + let page1 = rpc + .list_accounts(OLBlockOrTag::Slot(4), 0, 2) + .await + .expect("page 1"); + assert_eq!(page1.entries().len(), 2); + assert_eq!(page1.total(), 3); + assert_eq!(page1.next_offset(), Some(2)); + + // Second page: count=2 from offset 2. Only 1 entry left, no more pages. + let page2 = rpc + .list_accounts(OLBlockOrTag::Slot(4), 2, 2) + .await + .expect("page 2"); + assert_eq!(page2.entries().len(), 1); + assert_eq!(page2.total(), 3); + assert!(page2.next_offset().is_none()); + + // Walking past the end yields an empty page. + let page3 = rpc + .list_accounts(OLBlockOrTag::Slot(4), 10, 2) + .await + .expect("page 3"); + assert!(page3.entries().is_empty()); + assert_eq!(page3.total(), 3); + assert!(page3.next_offset().is_none()); +} + +#[tokio::test] +async fn list_accounts_resolves_confirmed_tag_to_confirmed_epoch() { + // Regression cover for the codex P2 finding: `Confirmed` must resolve to + // `confirmed_epoch`, not `prev_epoch`. Set them to different blocks with + // different ledger contents so a misroute would surface as a mismatched + // account count. + + let acct_a = test_account_id(1); + let acct_b = test_account_id(2); + + // Confirmed-state block has 1 account. + let confirmed_block = make_block(5, 1, null_blkid()); + let confirmed_blkid = confirmed_block.header().compute_blkid(); + let confirmed_epoch_commitment = EpochCommitment::new(1, 5, confirmed_blkid); + let confirmed_state = ol_state_with_n_empty_accounts(&[acct_a], 5); + + // Local-tip / prev-epoch block has 2 accounts (the bug would route here). + let prev_block = make_block(10, 2, confirmed_blkid); + let prev_blkid = prev_block.header().compute_blkid(); + let prev_epoch_commitment = EpochCommitment::new(2, 10, prev_blkid); + let prev_state = ol_state_with_n_empty_accounts(&[acct_a, acct_b], 10); + + let tip = OLBlockCommitment::new(10, prev_blkid); + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 2, + false, + prev_epoch_commitment, + confirmed_epoch_commitment, + EpochCommitment::null(), + )) + .with_block_and_state(&confirmed_block, confirmed_state) + .with_block_and_state(&prev_block, prev_state); + let rpc = make_rpc(provider); + + let page = rpc + .list_accounts(OLBlockOrTag::Confirmed, 0, 100) + .await + .expect("confirmed page"); + assert_eq!( + page.total(), + 1, + "confirmed tag must resolve to confirmed_epoch (1 account), \ + not prev_epoch (would be 2 accounts)" + ); +} From 132af3959745841b05f2b68c6b38db4274d74164 Mon Sep 17 00:00:00 2001 From: ashish Date: Sun, 3 May 2026 22:05:38 +0545 Subject: [PATCH 4/4] review(self): collapse redundant tx/account discriminators into tagged enums - RpcOLTxDetail: drop type_id (u16) and type_name (String); replace the separate sau: Option field with a tagged kind enum RpcOLTxKind { GenericAccountMessage, SnarkAccountUpdate(RpcSauTxSummary) }. Mirrors the existing RpcTransactionPayload pattern in the same module. Also makes target Option instead of falling back to all-zero bytes when the underlying tx has no target. - RpcAccountEntry: same treatment. Drop the standalone account_type and snark fields; replace with a tagged kind enum RpcAccountKind { Empty, Snark(RpcAccountSnarkSummary) }. Convenience snark() accessor preserved. - New regression test get_block_transactions_decodes_gam_tx that constructs a real OLTransaction in a non-empty block and asserts the From<&OLTransaction> path round-trips kind, target, and effects. Refs STR-3097. --- bin/strata/src/rpc/node_tests.rs | 66 +++++++++++++++++++++- crates/ol/rpc/types/src/account_state.rs | 48 ++++++++-------- crates/ol/rpc/types/src/lib.rs | 4 +- crates/ol/rpc/types/src/tx.rs | 72 ++++++++++-------------- 4 files changed, 120 insertions(+), 70 deletions(-) diff --git a/bin/strata/src/rpc/node_tests.rs b/bin/strata/src/rpc/node_tests.rs index bbfcfe551d..68864db988 100644 --- a/bin/strata/src/rpc/node_tests.rs +++ b/bin/strata/src/rpc/node_tests.rs @@ -370,6 +370,31 @@ fn make_block(slot: u64, epoch: u32, parent: OLBlockId) -> OLBlock { OLBlock::new(signed, body) } +fn make_block_with_gam_tx( + slot: u64, + epoch: u32, + parent: OLBlockId, + tx_target: AccountId, + tx_data: Vec, +) -> OLBlock { + let header = OLBlockHeader::new( + 0, + 0.into(), + slot, + epoch, + parent, + Buf32::zero(), + Buf32::zero(), + Buf32::zero(), + ); + let signed = SignedOLBlockHeader::new(header, Buf64::zero()); + let tx_data_inner = OLTransactionData::new_gam(tx_target, tx_data); + let tx = OLTransaction::new(tx_data_inner, TxProofs::new_empty()); + let segment = OLTxSegment::new(vec![tx]).expect("segment with one tx"); + let body = OLBlockBody::new_common(segment); + OLBlock::new(signed, body) +} + fn genesis_ol_state() -> OLState { let params = OLParams::new_empty(test_l1_commitment()); OLState::from_genesis_params(¶ms).expect("genesis state") @@ -2170,6 +2195,45 @@ async fn get_block_transactions_unknown_slot_errors() { assert!(result.is_err()); } +#[tokio::test] +async fn get_block_transactions_decodes_gam_tx() { + // Exercises the From<&OLTransaction> conversion path with a real, + // non-empty block. `OLTransactionData::new_gam` pushes the data into + // tx effects as a message, so we can assert both the kind and the + // effects round-trip correctly. + let target = test_account_id(0xab); + let payload_bytes = b"hello".to_vec(); + let block = make_block_with_gam_tx(7, 1, null_blkid(), target, payload_bytes.clone()); + let blkid = block.header().compute_blkid(); + let tip = OLBlockCommitment::new(7, blkid); + + let provider = MockProvider::new() + .with_sync_status(make_sync_status( + tip, + 1, + false, + EpochCommitment::null(), + EpochCommitment::null(), + EpochCommitment::null(), + )) + .with_block_and_state(&block, genesis_ol_state()); + let rpc = make_rpc(provider); + + let txs = rpc.get_block_transactions(7).await.expect("get txs"); + assert_eq!(txs.len(), 1); + let detail = &txs[0]; + assert!(matches!(detail.kind(), RpcOLTxKind::GenericAccountMessage)); + assert_eq!( + detail.target().expect("gam target").0, + *target.inner(), + "target round-trips through HexBytes32" + ); + let messages = detail.effects().messages(); + assert_eq!(messages.len(), 1, "gam pushes one message effect"); + assert_eq!(messages[0].dest().0, *target.inner()); + assert_eq!(messages[0].data().0, payload_bytes); +} + // ── list_accounts ── #[tokio::test] @@ -2203,7 +2267,7 @@ async fn list_accounts_returns_ledger_entries() { .iter() .find(|e| e.id().0 == *acct.inner()) .expect("account present in ledger"); - assert_eq!(our_entry.account_type(), RpcAccountType::Snark); + assert!(matches!(our_entry.kind(), RpcAccountKind::Snark(_))); let snark = our_entry.snark().expect("snark summary"); assert_eq!(snark.seq_no(), 7); assert_eq!(page.total(), page.entries().len() as u64); diff --git a/crates/ol/rpc/types/src/account_state.rs b/crates/ol/rpc/types/src/account_state.rs index b5c3aaef79..19109e6feb 100644 --- a/crates/ol/rpc/types/src/account_state.rs +++ b/crates/ol/rpc/types/src/account_state.rs @@ -71,10 +71,8 @@ pub struct RpcAccountEntry { serial: u32, /// Balance in sats. balance_sats: u64, - /// Account type: "empty" or "snark". - account_type: RpcAccountType, - /// Snark-specific summary, present only when `account_type == "snark"`. - snark: Option, + /// Account-type discriminator with type-specific fields inline. + kind: RpcAccountKind, } impl RpcAccountEntry { @@ -90,48 +88,50 @@ impl RpcAccountEntry { self.balance_sats } - pub fn account_type(&self) -> RpcAccountType { - self.account_type + pub fn kind(&self) -> &RpcAccountKind { + &self.kind } + /// Convenience accessor: returns the snark summary when the account + /// is a snark account, `None` otherwise. pub fn snark(&self) -> Option<&RpcAccountSnarkSummary> { - self.snark.as_ref() + match &self.kind { + RpcAccountKind::Snark(summary) => Some(summary), + RpcAccountKind::Empty => None, + } } } impl From<&TsnlAccountEntry> for RpcAccountEntry { fn from(entry: &TsnlAccountEntry) -> Self { let state = entry.state(); - let snark_summary = state.as_snark_account().ok().map(|snark| { - let inner_state = snark.inner_state_root(); - RpcAccountSnarkSummary { + let kind = match state.as_snark_account().ok() { + Some(snark) => RpcAccountKind::Snark(RpcAccountSnarkSummary { seq_no: *snark.seqno().inner(), - inner_state_root: HexBytes32::from(inner_state.0), + inner_state_root: HexBytes32::from(snark.inner_state_root().0), next_inbox_msg_idx: snark.next_inbox_msg_idx(), - } - }); - let account_type = if snark_summary.is_some() { - RpcAccountType::Snark - } else { - RpcAccountType::Empty + }), + None => RpcAccountKind::Empty, }; Self { id: HexBytes32::from(<[u8; 32]>::from(entry.id())), serial: *state.serial().inner(), balance_sats: state.balance().to_sat(), - account_type, - snark: snark_summary, + kind, } } } -/// Account type discriminator for [`RpcAccountEntry`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Account-type discriminator for [`RpcAccountEntry`]. +/// +/// Mirrors the on-chain `OLAccountTypeState` and carries snark-specific +/// summary data inline so the wire format is self-describing. +#[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] -#[serde(rename_all = "snake_case")] -pub enum RpcAccountType { +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RpcAccountKind { Empty, - Snark, + Snark(RpcAccountSnarkSummary), } /// Snark-account summary fields surfaced in account listings. diff --git a/crates/ol/rpc/types/src/lib.rs b/crates/ol/rpc/types/src/lib.rs index 1b55a4e329..12ad2a2fe2 100644 --- a/crates/ol/rpc/types/src/lib.rs +++ b/crates/ol/rpc/types/src/lib.rs @@ -15,7 +15,7 @@ mod snark_acct_update; mod tx; pub use account_state::{ - RpcAccountEntry, RpcAccountListPage, RpcAccountSnarkSummary, RpcAccountType, + RpcAccountEntry, RpcAccountKind, RpcAccountListPage, RpcAccountSnarkSummary, RpcSnarkAccountState, }; pub use account_summary::{ @@ -31,7 +31,7 @@ pub use duty::*; pub use provider::OLRpcProvider; pub use snark_acct_update::RpcSnarkAccountUpdate; pub use tx::{ - RpcGenericAccountMessage, RpcOLTransaction, RpcOLTxDetail, RpcSauTxSummary, + RpcGenericAccountMessage, RpcOLTransaction, RpcOLTxDetail, RpcOLTxKind, RpcSauTxSummary, RpcSentMessageEffect, RpcSentTransfer, RpcTransactionPayload, RpcTxConstraints, RpcTxConversionError, RpcTxEffectsView, }; diff --git a/crates/ol/rpc/types/src/tx.rs b/crates/ol/rpc/types/src/tx.rs index 612bb069c3..9b42482749 100644 --- a/crates/ol/rpc/types/src/tx.rs +++ b/crates/ol/rpc/types/src/tx.rs @@ -14,11 +14,6 @@ use strata_snark_acct_types::{SnarkAccountUpdate, UpdateOperationData}; use crate::RpcSnarkAccountUpdate; -/// Type discriminator for GAM transactions in [`RpcOLTxDetail::type_id`]. -const TX_TYPE_ID_GAM: u16 = 0; -/// Type discriminator for SAU transactions in [`RpcOLTxDetail::type_id`]. -const TX_TYPE_ID_SAU: u16 = 1; - /// OL transaction for submission (excludes accumulator proofs). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] @@ -161,25 +156,21 @@ pub enum RpcTxConversionError { /// Decoded view of a transaction included in a block. /// /// Returned by `strata_getBlockTransactions`. Carries the computed txid, the -/// payload type, the target account, constraints, and a summary of effects. -/// SAU-specific fields are populated when `type_id == 1`. +/// target account (if any), constraints, effects, and a tagged +/// [`RpcOLTxKind`] that carries any payload-type-specific fields inline. #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] pub struct RpcOLTxDetail { /// Computed transaction ID (tree hash of `OLTransactionData`). txid: OLTxId, - /// Numeric type discriminator: 0 = GAM, 1 = SAU. - type_id: u16, - /// Human-readable type name. - type_name: String, - /// Target account. - target: HexBytes32, + /// Target account, or `None` if this transaction type carries no target. + target: Option, /// Inclusion constraints. constraints: RpcTxConstraints, /// Effects produced when this transaction is applied. effects: RpcTxEffectsView, - /// SAU-specific fields, present only when `type_id == 1`. - sau: Option, + /// Payload-type discriminator with type-specific fields inline. + kind: RpcOLTxKind, } impl RpcOLTxDetail { @@ -187,16 +178,8 @@ impl RpcOLTxDetail { self.txid } - pub fn type_id(&self) -> u16 { - self.type_id - } - - pub fn type_name(&self) -> &str { - &self.type_name - } - - pub fn target(&self) -> &HexBytes32 { - &self.target + pub fn target(&self) -> Option<&HexBytes32> { + self.target.as_ref() } pub fn constraints(&self) -> &RpcTxConstraints { @@ -207,8 +190,8 @@ impl RpcOLTxDetail { &self.effects } - pub fn sau(&self) -> Option<&RpcSauTxSummary> { - self.sau.as_ref() + pub fn kind(&self) -> &RpcOLTxKind { + &self.kind } } @@ -216,36 +199,39 @@ impl From<&OLTransaction> for RpcOLTxDetail { fn from(tx: &OLTransaction) -> Self { let txid = tx.compute_txid(); let data = tx.data(); - let (type_id, type_name, sau) = match data.payload() { - TransactionPayload::GenericAccountMessage(_) => ( - TX_TYPE_ID_GAM, - "generic_account_message".to_string(), - None, - ), - TransactionPayload::SnarkAccountUpdate(sau_payload) => ( - TX_TYPE_ID_SAU, - "snark_account_update".to_string(), - Some(RpcSauTxSummary::from(sau_payload)), - ), + let kind = match data.payload() { + TransactionPayload::GenericAccountMessage(_) => RpcOLTxKind::GenericAccountMessage, + TransactionPayload::SnarkAccountUpdate(sau_payload) => { + RpcOLTxKind::SnarkAccountUpdate(RpcSauTxSummary::from(sau_payload)) + } }; let target = tx .target() - .map(|a| HexBytes32::from(<[u8; 32]>::from(a))) - .unwrap_or_else(|| HexBytes32::from([0u8; 32])); + .map(|a| HexBytes32::from(<[u8; 32]>::from(a))); let constraints = RpcTxConstraints::from(data.constraints().clone()); let effects = RpcTxEffectsView::from(data.effects()); Self { txid, - type_id, - type_name, target, constraints, effects, - sau, + kind, } } } +/// Payload-type discriminator for [`RpcOLTxDetail`]. +/// +/// Mirrors the on-chain [`TransactionPayload`] enum and carries any +/// type-specific summary data inline, so the wire format is self-describing. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RpcOLTxKind { + GenericAccountMessage, + SnarkAccountUpdate(RpcSauTxSummary), +} + /// Summary of transfers and messages produced by a transaction. #[derive(Clone, Debug, Default, Serialize, Deserialize)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))]