diff --git a/Cargo.lock b/Cargo.lock index 1156d1b..cd574d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,6 +760,7 @@ dependencies = [ "ethereum_ssz", "ethereum_ssz_derive", "sequencer-core", + "thiserror 1.0.69", "tracing", "types", ] diff --git a/README.md b/README.md index d2a555e..aec297b 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,124 @@ Required: `SEQ_ETH_RPC_URL`, `SEQ_CHAIN_ID`, `SEQ_APP_ADDRESS`, `SEQ_BATCH_SUBMI Optional: `SEQ_HTTP_ADDR` (default `127.0.0.1:3000`), `SEQ_DATA_DIR` (default `sequencer-data`), `SEQ_PREEMPTIVE_MARGIN_BLOCKS` (default `300`), `SEQ_SECONDS_PER_BLOCK` (default `12`), `SEQ_BATCH_SUBMITTER_IDLE_POLL_INTERVAL_MS`, `SEQ_BATCH_SUBMITTER_CONFIRMATION_DEPTH`. +- `SEQ_HTTP_ADDR` defaults to `127.0.0.1:3000` +- `SEQ_DATA_DIR` defaults to `sequencer-data` (SQLite file is `sequencer.db` inside that directory; the directory is created if missing) +- `SEQ_LONG_BLOCK_RANGE_ERROR_CODES` defaults to `-32005,-32600,-32602,-32616` +- `SEQ_BATCH_SUBMITTER_PRIVATE_KEY_FILE` instead of `SEQ_BATCH_SUBMITTER_PRIVATE_KEY` (first line of the file is the key) +- `SEQ_BATCH_SUBMITTER_IDLE_POLL_INTERVAL_MS`, `SEQ_BATCH_SUBMITTER_CONFIRMATION_DEPTH` + +Required runtime inputs: + +- `SEQ_ETH_RPC_URL` +- `SEQ_CHAIN_ID` +- `SEQ_APP_ADDRESS` +- `SEQ_BATCH_SUBMITTER_PRIVATE_KEY` or `SEQ_BATCH_SUBMITTER_PRIVATE_KEY_FILE` + +Fixed protocol identity (EIP-712): + +- domain name: `CartesiAppSequencer` +- domain version: `1` +- `chain_id` and `verifying_contract` come from `SEQ_CHAIN_ID` and `SEQ_APP_ADDRESS` + +Most queue sizes, polling intervals, and safety limits are now internal runtime constants instead of public launch-time configuration. + +## API + +### `POST /tx` + +Request shape: + +```json +{ + "message": { + "nonce": 0, + "max_fee": 1, + "data": "0x..." + }, + "signature": "0x...", + "sender": "0x..." +} +``` + +Notes: + +- `signature` must be 65 bytes. +- `sender` is required and must match the recovered signer. +- `message.data` is SSZ-encoded method payload bytes. +- payload size is bounded at ingress; oversized requests are rejected before entering the hot path. +- overload is enforced at queue admission: if the inclusion-lane queue is full, `POST /tx` returns HTTP `429` with code `OVERLOADED` and message `queue full`. +- queue capacity is an internal runtime constant tuned alongside inclusion-lane chunking to absorb short bursts; if this starts triggering persistently, it is a signal to revisit runtime sizing or throughput rather than add another admission layer. + +### `GET /ws/subscribe?from_offset=` + +WebSocket stream of sequenced L2 transactions from persisted order. + +Notes: + +- `from_offset` is optional and defaults to `0`. +- messages are JSON text frames. +- binary fields are hex-encoded (`0x`-prefixed). +- the current runtime enforces a subscriber cap of `64` and a catch-up cap of `50000` events. +- if the requested catch-up window exceeds that cap, the server upgrades and then immediately closes the socket with close code `1008` (`POLICY`) and reason `catch-up window exceeded`. + +Message shapes: + +```json +{ "kind": "user_op", "offset": 10, "sender": "0x...", "fee": 1, "data": "0x..." } +``` + +```json +{ "kind": "direct_input", "offset": 11, "payload": "0x..." } +``` + +Success response: + +```json +{ + "ok": true, + "sender": "0x...", + "nonce": 0 +} +``` + +## Storage Model + +- `batches`: batch metadata +- `frames`: frame boundaries within each batch +- `frames.fee`: committed fee for each frame +- `user_ops`: included user operations +- `sequenced_l2_txs`: append-only ordered replay rows (`UserOp` xor `DirectInput`); inserting into `user_ops` also appends the corresponding replay row via trigger `trg_sequence_user_op` +- `safe_inputs`: direct-input payload stream +- `batch_policy`: singleton knobs and constants for DA-style batch sizing and fee derivation; `batch_policy_derived` view exposes `recommended_fee` and `batch_size_target` + +## Project Layout + +- `sequencer/src/main.rs`: thin binary entrypoint +- `sequencer/src/lib.rs`: public crate surface +- `sequencer/src/config.rs`: runtime input parsing and EIP-712 domain construction +- `sequencer/src/runtime.rs`: sequencer bootstrap and component wiring +- `sequencer/src/api/`: HTTP API and error mapping +- `sequencer/src/inclusion_lane/`: hot-path inclusion loop, chunk/frame/batch rotation, catch-up +- `sequencer/src/input_reader/`: safe-input ingestion from InputBox into SQLite +- `sequencer/src/l2_tx_feed/`: DB-backed ordered-L2Tx feed for WS subscriptions +- `sequencer/src/storage/`: schema, migrations, SQLite persistence, and replay reads +- `sequencer-core/src/`: shared domain types and interfaces (`Application`, `SignedUserOp`, `SequencedL2Tx`, feed message types) +- `examples/app-core/src/`: wallet prototype implementing `Application` +- `tests/benchmarks/`: benchmark harnesses and benchmark spec + +Related docs: +- App snapshot format: `docs/app-snapshot-format.md` + +## Prototype Limits + +- Wallet state is in-memory and not persisted. +- Schema and migrations are still in prototype mode and may change. + +## Local Test Prerequisites + +- Some `sequencer` tests spin up `Anvil`; install Foundry locally if you want the full test suite: +- Self-contained benchmarks also spawn `Anvil` from a preloaded rollups state dump. + ## Development ```bash diff --git a/docs/app-snapshot-format.md b/docs/app-snapshot-format.md new file mode 100644 index 0000000..77a99e2 --- /dev/null +++ b/docs/app-snapshot-format.md @@ -0,0 +1,69 @@ +# App Snapshot Format (Wallet Toy App) + +This document defines the current on-disk snapshot format for the toy wallet app in `examples/app-core`. + +Scope note: this only covers the **capability** to serialize/deserialize app state. It does not define when snapshots are triggered or how runtime wiring invokes save/load. + +## Encoding + +- **Format:** SSZ +- **Current version:** `WalletSnapshotV1` (Rust struct name) +- **Byte order for balances:** big-endian 32-byte integers (`U256`) + +## Serialized State + +`WalletSnapshotV1` encodes: + +- `erc20_portal_address` (`[u8; 20]`) +- `supported_erc20_token` (`[u8; 20]`) +- `sequencer_address` (`[u8; 20]`) +- `balances` (`Vec`) + - `address` (`[u8; 20]`) + - `balance_be` (`[u8; 32]`) +- `nonces` (`Vec`) + - `address` (`[u8; 20]`) + - `nonce` (`u32`) +- `executed_input_count` (`u64`) + +## Determinism Guarantees + +`WalletApp` stores balances/nonces in hash maps, so iteration order is nondeterministic. Before encoding: + +- `balances` entries are sorted by `address` +- `nonces` entries are sorted by `address` + +This guarantees stable snapshot bytes for equivalent logical state. + +## Compatibility Policy + +- Restores must decode the exact current snapshot schema. +- Malformed bytes fail restore with a decode error. +- Future breaking changes must introduce a new versioned schema type (for example, `WalletSnapshotV2`) and explicit migration/dispatch logic. +- Do not reorder or reinterpret existing fields in-place without a version bump. + +## API Surface + +Current wallet snapshot API: + +- `snapshot_bytes(&self) -> Vec` +- `restore_from_snapshot_bytes(&mut self, snapshot: &[u8]) -> Result<(), WalletSnapshotError>` +- `save_snapshot>(&self, path: P) -> Result<(), WalletSnapshotError>` +- `load_snapshot>(&mut self, path: P) -> Result<(), WalletSnapshotError>` + +## Disk Write Semantics + +`save_snapshot` uses an atomic replacement pattern: + +- write bytes to a temporary file in the same directory as the target +- `sync_all` the temp file +- rename temp file to the final path + +This avoids exposing partially written snapshot bytes at the target path. + +## Out of Scope + +This document intentionally does not define: + +- periodic vs explicit snapshot trigger policy +- mount paths and runtime drive conventions +- atomic file replacement protocol for production snapshot lifecycle diff --git a/examples/app-core/Cargo.toml b/examples/app-core/Cargo.toml index 133bc58..bdbd62e 100644 --- a/examples/app-core/Cargo.toml +++ b/examples/app-core/Cargo.toml @@ -17,3 +17,4 @@ ssz = { package = "ethereum_ssz", version = "0.10" } ssz_derive = { package = "ethereum_ssz_derive", version = "0.10" } tracing = "0.1" types = { workspace = true } +thiserror = "1" diff --git a/examples/app-core/src/application/mod.rs b/examples/app-core/src/application/mod.rs index 78d7cea..04a3dfe 100644 --- a/examples/app-core/src/application/mod.rs +++ b/examples/app-core/src/application/mod.rs @@ -9,4 +9,4 @@ mod wallet; pub use anvil_accounts::default_private_keys; pub use method::{MAX_METHOD_PAYLOAD_BYTES, Method, Transfer, Withdrawal}; pub use notice::{DepositNotice, TransferNotice}; -pub use wallet::{WalletApp, WalletConfig}; +pub use wallet::{WalletApp, WalletConfig, WalletSnapshotError}; diff --git a/examples/app-core/src/application/wallet.rs b/examples/app-core/src/application/wallet.rs index d4f5f55..bfef190 100644 --- a/examples/app-core/src/application/wallet.rs +++ b/examples/app-core/src/application/wallet.rs @@ -2,9 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 (see LICENSE) use std::collections::HashMap; +use std::io::Write; +use std::path::Path; use alloy_primitives::{Address, U256, address}; -use ssz::Decode; +use ssz::{Decode, Encode}; +use ssz_derive::{Decode as SszDecode, Encode as SszEncode}; +use thiserror::Error; use tracing::{error, warn}; use types::alloy_sol_types::SolCall; use types::{Erc20Deposit, Erc20Transfer}; @@ -56,6 +60,42 @@ pub struct WalletApp { executed_input_count: u64, } +#[derive(Debug, Error)] +pub enum WalletSnapshotError { + #[error("snapshot decode failed: {0}")] + Decode(String), + #[error("snapshot I/O failed: {0}")] + Io(#[from] std::io::Error), +} + +impl From for WalletSnapshotError { + fn from(value: ssz::DecodeError) -> Self { + Self::Decode(format!("{value:?}")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +struct SnapshotBalance { + address: [u8; 20], + balance_be: [u8; 32], +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +struct SnapshotNonce { + address: [u8; 20], + nonce: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, SszEncode, SszDecode)] +struct WalletSnapshotV1 { + erc20_portal_address: [u8; 20], + supported_erc20_token: [u8; 20], + sequencer_address: [u8; 20], + balances: Vec, + nonces: Vec, + executed_input_count: u64, +} + pub const SEPOLIA_ERC20_PORTAL_ADDRESS: Address = address!("0xACA6586A0Cf05bD831f2501E7B4aea550dA6562D"); pub const SEPOLIA_USDC_ADDRESS: Address = address!("0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"); @@ -112,6 +152,95 @@ impl WalletApp { Erc20Deposit::decode(&input.payload).map(Some) } + + pub fn snapshot_bytes(&self) -> Vec { + let mut balances: Vec<_> = self + .balances + .iter() + .map(|(address, balance)| SnapshotBalance { + address: address.into_array(), + balance_be: balance.to_be_bytes(), + }) + .collect(); + balances.sort_unstable_by_key(|entry| entry.address); + + let mut nonces: Vec<_> = self + .nonces + .iter() + .map(|(address, nonce)| SnapshotNonce { + address: address.into_array(), + nonce: *nonce, + }) + .collect(); + nonces.sort_unstable_by_key(|entry| entry.address); + + WalletSnapshotV1 { + erc20_portal_address: self.config.erc20_portal_address.into_array(), + supported_erc20_token: self.config.supported_erc20_token.into_array(), + sequencer_address: self.config.sequencer_address.into_array(), + balances, + nonces, + executed_input_count: self.executed_input_count, + } + .as_ssz_bytes() + } + + pub fn restore_from_snapshot_bytes( + &mut self, + snapshot: &[u8], + ) -> Result<(), WalletSnapshotError> { + let decoded = WalletSnapshotV1::from_ssz_bytes(snapshot)?; + self.config = WalletConfig { + erc20_portal_address: Address::from(decoded.erc20_portal_address), + supported_erc20_token: Address::from(decoded.supported_erc20_token), + sequencer_address: Address::from(decoded.sequencer_address), + }; + self.balances = decoded + .balances + .into_iter() + .map(|entry| { + ( + Address::from(entry.address), + U256::from_be_bytes(entry.balance_be), + ) + }) + .collect(); + self.nonces = decoded + .nonces + .into_iter() + .map(|entry| (Address::from(entry.address), entry.nonce)) + .collect(); + self.executed_input_count = decoded.executed_input_count; + Ok(()) + } + + pub fn save_snapshot>(&self, path: P) -> Result<(), WalletSnapshotError> { + let path = path.as_ref(); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("wallet-snapshot"); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let temp_path = parent.join(format!(".{file_name}.tmp-{}-{}", std::process::id(), nanos)); + + let bytes = self.snapshot_bytes(); + let mut temp_file = std::fs::File::create(&temp_path)?; + temp_file.write_all(&bytes)?; + temp_file.sync_all()?; + drop(temp_file); + + std::fs::rename(&temp_path, path)?; + Ok(()) + } + + pub fn load_snapshot>(&mut self, path: P) -> Result<(), WalletSnapshotError> { + let bytes = std::fs::read(path)?; + self.restore_from_snapshot_bytes(&bytes) + } } impl Default for WalletApp { @@ -257,6 +386,9 @@ impl Application for WalletApp { #[cfg(test)] mod tests { + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + use alloy_primitives::{Address, U256, address}; use ssz_derive::{Decode, Encode}; use types::ERC20_DEPOSIT_PREFIX_BYTES; @@ -645,4 +777,81 @@ mod tests { // Fee goes to address zero — effectively burned. assert_eq!(app.current_user_balance(Address::ZERO), gas_cost); } + + #[test] + fn snapshot_roundtrip_restores_full_state() { + let mut app = WalletApp::new(WalletConfig { + erc20_portal_address: address!("0x1212121212121212121212121212121212121212"), + supported_erc20_token: address!("0x3434343434343434343434343434343434343434"), + sequencer_address: address!("0x5656565656565656565656565656565656565656"), + }); + let alice = address!("0x1111111111111111111111111111111111111111"); + let bob = address!("0x2222222222222222222222222222222222222222"); + app.balances.insert(alice, U256::from(1234_u64)); + app.balances.insert(bob, U256::from(5678_u64)); + app.nonces.insert(alice, 4); + app.nonces.insert(bob, 9); + app.executed_input_count = 42; + + let snapshot = app.snapshot_bytes(); + let mut restored = WalletApp::default(); + restored + .restore_from_snapshot_bytes(&snapshot) + .expect("snapshot should decode"); + + assert_eq!( + restored.config.erc20_portal_address, + app.config.erc20_portal_address + ); + assert_eq!( + restored.config.supported_erc20_token, + app.config.supported_erc20_token + ); + assert_eq!( + restored.config.sequencer_address, + app.config.sequencer_address + ); + assert_eq!(restored.balances, app.balances); + assert_eq!(restored.nonces, app.nonces); + assert_eq!(restored.executed_input_count, app.executed_input_count); + } + + #[test] + fn save_then_load_snapshot_persists_state_to_disk() { + let mut app = WalletApp::new(WalletConfig::default()); + let user = address!("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + app.balances.insert(user, U256::from(999_u64)); + app.nonces.insert(user, 3); + app.executed_input_count = 11; + + let path = temp_snapshot_file_path("wallet-snapshot"); + app.save_snapshot(&path).expect("save snapshot"); + + let mut loaded = WalletApp::default(); + loaded.load_snapshot(&path).expect("load snapshot"); + std::fs::remove_file(&path).expect("cleanup snapshot file"); + + assert_eq!(loaded.balances, app.balances); + assert_eq!(loaded.nonces, app.nonces); + assert_eq!(loaded.executed_input_count, app.executed_input_count); + } + + #[test] + fn restore_rejects_malformed_snapshot() { + let mut app = WalletApp::default(); + let err = app + .restore_from_snapshot_bytes(&[0x01, 0x02, 0x03]) + .expect_err("invalid bytes should fail"); + assert!(matches!(err, super::WalletSnapshotError::Decode(_))); + } + + fn temp_snapshot_file_path(prefix: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock before epoch") + .as_nanos(); + path.push(format!("{prefix}-{}-{nanos}.bin", std::process::id())); + path + } }