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..302cd12efc 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, + 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}; @@ -151,6 +152,49 @@ 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 => { + self.provider + .get_ol_sync_status() + .ok_or_else(|| internal_error("OL sync status not available"))? + .confirmed_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 +938,105 @@ 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 + ))); + } + + // 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 + .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, + 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 + .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 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(); + 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 f4c5b911f7..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") @@ -410,6 +435,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; @@ -1992,3 +2028,399 @@ 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()); +} + +#[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] +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 page = rpc + .list_accounts(OLBlockOrTag::Slot(4), 0, 100) + .await + .expect("list accounts"); + let our_entry = page + .entries() + .iter() + .find(|e| e.id().0 == *acct.inner()) + .expect("account present in ledger"); + 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); + 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()); +} + +#[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)" + ); +} diff --git a/crates/ol/rpc/api/src/lib.rs b/crates/ol/rpc/api/src/lib.rs index b1d67f1005..6e6bc2ec10 100644 --- a/crates/ol/rpc/api/src/lib.rs +++ b/crates/ol/rpc/api/src/lib.rs @@ -99,6 +99,40 @@ 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 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, + start: u64, + count: u64, + ) -> 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..19109e6feb 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,140 @@ 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 discriminator with type-specific fields inline. + kind: RpcAccountKind, +} + +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 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> { + 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 kind = match state.as_snark_account().ok() { + Some(snark) => RpcAccountKind::Snark(RpcAccountSnarkSummary { + seq_no: *snark.seqno().inner(), + inner_state_root: HexBytes32::from(snark.inner_state_root().0), + next_inbox_msg_idx: snark.next_inbox_msg_idx(), + }), + None => RpcAccountKind::Empty, + }; + Self { + id: HexBytes32::from(<[u8; 32]>::from(entry.id())), + serial: *state.serial().inner(), + balance_sats: state.balance().to_sat(), + kind, + } + } +} + +/// 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(tag = "type", rename_all = "snake_case")] +pub enum RpcAccountKind { + Empty, + Snark(RpcAccountSnarkSummary), +} + +/// 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 + } +} + +/// 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/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..12ad2a2fe2 100644 --- a/crates/ol/rpc/types/src/lib.rs +++ b/crates/ol/rpc/types/src/lib.rs @@ -14,11 +14,16 @@ mod provider; mod snark_acct_update; mod tx; -pub use account_state::RpcSnarkAccountState; +pub use account_state::{ + RpcAccountEntry, RpcAccountKind, RpcAccountListPage, RpcAccountSnarkSummary, + 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 +31,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, 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 ae70ca5398..9b42482749 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, @@ -152,6 +153,211 @@ pub enum RpcTxConversionError { TooManyAsmHistoryClaims, } +/// Decoded view of a transaction included in a block. +/// +/// Returned by `strata_getBlockTransactions`. Carries the computed txid, the +/// 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, + /// 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, + /// Payload-type discriminator with type-specific fields inline. + kind: RpcOLTxKind, +} + +impl RpcOLTxDetail { + pub fn txid(&self) -> OLTxId { + self.txid + } + + pub fn target(&self) -> Option<&HexBytes32> { + self.target.as_ref() + } + + pub fn constraints(&self) -> &RpcTxConstraints { + &self.constraints + } + + pub fn effects(&self) -> &RpcTxEffectsView { + &self.effects + } + + pub fn kind(&self) -> &RpcOLTxKind { + &self.kind + } +} + +impl From<&OLTransaction> for RpcOLTxDetail { + fn from(tx: &OLTransaction) -> Self { + let txid = tx.compute_txid(); + let data = tx.data(); + 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))); + let constraints = RpcTxConstraints::from(data.constraints().clone()); + let effects = RpcTxEffectsView::from(data.effects()); + Self { + txid, + target, + constraints, + effects, + 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))] +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: