diff --git a/Cargo.lock b/Cargo.lock index 2d42e6b..d0eb39c 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 d8b8997..6698939 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,9 @@ Success response: - `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. 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 e1db37a..05462d8 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 { @@ -183,32 +312,31 @@ impl Application for WalletApp { let method = Method::from_ssz_bytes(user_op.data.as_slice()).ok(); match method.as_ref() { - Some(Method::Transfer(transfer)) => { - if self.debit_if_possible(sender, transfer.amount) { - self.credit(transfer.to, transfer.amount); - outputs.push(AppOutput::Notice( - TransferNotice { - sender, - recipient: transfer.to, - amount: transfer.amount, - } - .abi_encode(), - )); - } + Some(Method::Transfer(transfer)) if self.debit_if_possible(sender, transfer.amount) => { + self.credit(transfer.to, transfer.amount); + outputs.push(AppOutput::Notice( + TransferNotice { + sender, + recipient: transfer.to, + amount: transfer.amount, + } + .abi_encode(), + )); } - Some(Method::Withdrawal(withdrawal)) => { - if self.debit_if_possible(sender, withdrawal.amount) { - outputs.push(AppOutput::Voucher { - destination: self.config.supported_erc20_token, - value: U256::ZERO, - payload: Erc20Transfer { - recipient: sender, - amount: withdrawal.amount, - } - .abi_encode(), - }); - } + Some(Method::Withdrawal(withdrawal)) + if self.debit_if_possible(sender, withdrawal.amount) => + { + outputs.push(AppOutput::Voucher { + destination: self.config.supported_erc20_token, + value: U256::ZERO, + payload: Erc20Transfer { + recipient: sender, + amount: withdrawal.amount, + } + .abi_encode(), + }); } + Some(_) => {} None => {} } @@ -265,6 +393,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; @@ -649,4 +780,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 + } } diff --git a/tests/benchmarks/src/bin/report.rs b/tests/benchmarks/src/bin/report.rs index a380edd..142b11c 100644 --- a/tests/benchmarks/src/bin/report.rs +++ b/tests/benchmarks/src/bin/report.rs @@ -7,6 +7,7 @@ use benchmarks::{ }; use clap::Parser; use serde_json::Value; +use std::cmp::Reverse; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -282,7 +283,7 @@ fn load_latest_multi_row_sweep(dir: &Path) -> Option