diff --git a/Cargo.lock b/Cargo.lock index 7640903e79..6cb2a933f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,8 +630,8 @@ dependencies = [ "serde_json", "serde_with", "thiserror 2.0.16", - "tree_hash", - "tree_hash_derive", + "tree_hash 0.10.0", + "tree_hash_derive 0.10.0", ] [[package]] @@ -3778,7 +3778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.106", ] [[package]] @@ -12093,7 +12093,7 @@ version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes 0.14.0", "rand 0.8.5", "secp256k1-sys 0.10.1", "serde", @@ -13170,6 +13170,62 @@ dependencies = [ "der", ] +[[package]] +name = "sps62-checkpoint-types" +version = "0.1.0" +dependencies = [ + "ssz", + "ssz_derive", + "ssz_types", + "tree_hash 0.2.0", + "tree_hash_derive 0.2.0", +] + +[[package]] +name = "ssz" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "hex", + "itertools 0.13.0", + "serde", + "smallvec", + "ssz_primitives", +] + +[[package]] +name = "ssz_derive" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "darling 0.20.11", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "ssz_primitives" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "hex", + "rand 0.8.5", + "ruint", +] + +[[package]] +name = "ssz_types" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "itertools 0.13.0", + "serde", + "serde_derive", + "ssz", + "ssz_primitives", + "tree_hash 0.2.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -15513,6 +15569,18 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tree_hash" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "digest 0.10.7", + "sha2 0.10.9", + "smallvec", + "ssz", + "ssz_primitives", +] + [[package]] name = "tree_hash" version = "0.10.0" @@ -15526,6 +15594,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "tree_hash_derive" +version = "0.2.0" +source = "git+https://github.com/alpenlabs/ssz-gen?branch=main#2e6d0ba5033c42eb960436914ccc75170f11a22a" +dependencies = [ + "darling 0.20.11", + "quote", + "syn 2.0.106", +] + [[package]] name = "tree_hash_derive" version = "0.10.0" diff --git a/Cargo.toml b/Cargo.toml index d4f2950e52..894a2dfd89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ members = [ "crates/service", "crates/simple-ee", "crates/snark-acct-types", + "crates/sps62-checkpoint-types", "crates/state", "crates/status", "crates/storage", @@ -396,6 +397,9 @@ sha2 = "0.10" shrex = { version = "1", features = ["serde"] } shrex_macros = "1" sled = "0.34.7" +ssz = { git = "https://github.com/alpenlabs/ssz-gen", branch = "main" } +ssz_derive = { git = "https://github.com/alpenlabs/ssz-gen", branch = "main" } +ssz_types = { git = "https://github.com/alpenlabs/ssz-gen", branch = "main" } tempfile = "3.10.1" terrors = "0.3.0" thiserror = "2.0.11" @@ -406,6 +410,8 @@ tower = "0.5" tracing = "0.1" tracing-opentelemetry = "0.27" tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } +tree_hash = { git = "https://github.com/alpenlabs/ssz-gen", branch = "main" } +tree_hash_derive = { git = "https://github.com/alpenlabs/ssz-gen", branch = "main" } uuid = { version = "1.0", features = ["v4", "serde"] } zeroize = { version = "1.8.1", features = ["derive"] } diff --git a/crates/sps62-checkpoint-types/Cargo.toml b/crates/sps62-checkpoint-types/Cargo.toml new file mode 100644 index 0000000000..d10a3e79fc --- /dev/null +++ b/crates/sps62-checkpoint-types/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sps62-checkpoint-types" +version = "0.1.0" +edition = "2021" +description = "SPS-62 checkpoint types with SSZ serialization" + +[lints] +workspace = true + +[dependencies] +ssz = { workspace = true } +ssz_derive = { workspace = true } +ssz_types = { workspace = true } +tree_hash = { workspace = true } +tree_hash_derive = { workspace = true } + +[dev-dependencies] diff --git a/crates/sps62-checkpoint-types/src/lib.rs b/crates/sps62-checkpoint-types/src/lib.rs new file mode 100644 index 0000000000..cfaf687e02 --- /dev/null +++ b/crates/sps62-checkpoint-types/src/lib.rs @@ -0,0 +1,10 @@ +//! SPS-62 checkpoint types with SSZ serialization. + +#![warn(missing_debug_implementations)] +#![warn(missing_docs)] + +mod types; + +pub use ssz; +pub use tree_hash; +pub use types::*; diff --git a/crates/sps62-checkpoint-types/src/types.rs b/crates/sps62-checkpoint-types/src/types.rs new file mode 100644 index 0000000000..2ff470238d --- /dev/null +++ b/crates/sps62-checkpoint-types/src/types.rs @@ -0,0 +1,274 @@ +//! Core SPS-62 checkpoint type definitions. + +use ssz_derive::{Decode, Encode}; +use ssz_types::VariableList; +use tree_hash_derive::TreeHash; + +/// Maximum size of OL DA state diff: 256 KiB (2^18 bytes) +pub const OL_DA_DIFF_MAX_SIZE: usize = 1 << 18; + +/// Maximum size of output message blob: 16 KiB (2^14 bytes) +pub const OUTPUT_MSG_MAX_SIZE: usize = 1 << 14; + +/// A 32-byte buffer (used for block IDs, state roots, public keys, etc.) +pub type Bytes32 = [u8; 32]; + +/// A 64-byte buffer (used for signatures) +pub type Bytes64 = [u8; 64]; + +/// Variable-length byte list for OL state diff (max 256 KiB) +pub type OlStateDiff = VariableList; // 2^18 + +/// Variable-length byte list for output messages (max 16 KiB) +pub type OutputMsgBlob = VariableList; // 2^14 + +/// Checkpoint header containing minimally necessary information to construct an EpochSummary. +/// +/// This structure is ordered for optimal SSZ packing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct CheckpointHeader { + /// Epoch number + pub epoch: u32, + + /// L1 view update height (ordering for compactness) + pub l1_view_update_height: u32, + + /// Terminal slot number + pub terminal_slot: u64, + + /// Terminal block ID + pub terminal_blkid: Bytes32, + + /// Final state root + pub final_state_root: Bytes32, +} + +/// Checkpoint payload containing various "output" components. +/// +/// This includes all messages that originate from L2 such as withdrawals. +/// This is partly intended to be consumed by ASM and treated as an opaque type. +/// +/// Note: The spec shows this as a StableContainer, but the SSZ library doesn't support +/// StableContainer yet, so we use a regular Container with Option fields. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct CheckpointPayload { + /// Encoded DA state diff. Checked by proof to ensure it matches state. + /// + /// Maximum size: [`OL_DA_DIFF_MAX_SIZE`] (256 KiB) + pub ol_state_diff: Option, + + /// Output messages from OL. This corresponds to (some subset of?) OL logs. + /// + /// Maximum size: [`OUTPUT_MSG_MAX_SIZE`] (16 KiB) + pub packed_output_msgs: Option, +} + +/// Container for data we interpret about the checkpoint. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct CheckpointData { + /// Checkpoint header + pub header: CheckpointHeader, + + /// Checkpoint payload + pub payload: CheckpointPayload, + + /// OL state diff (encoded) + /// + /// Maximum size: [`OL_DA_DIFF_MAX_SIZE`] (256 KiB) + pub ol_state_diff: OlStateDiff, +} + +/// Toplevel bundle for the checkpoint data and a proof of it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct Checkpoint { + /// Checkpoint data + pub data: CheckpointData, + + /// Proof witness (variable-length) + /// + /// TODO: Replace with actual unipred::Witness type when available. + /// For now, using a bounded list. Adjust max size as needed. + pub proof: VariableList, // Max 256 KiB for proof +} + +/// Signed checkpoint bundle (does not normally exist on-chain). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct SignedCheckpoint { + /// Schnorr signature (64 bytes) + pub sig: Bytes64, + + /// Checkpoint bundle + pub data: Checkpoint, +} + +/// Summary of information committing to the final state of an epoch. +/// +/// Ordered to pack well in SSZ. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, TreeHash)] +pub struct EpochSummary { + /// Epoch index + pub epoch_idx: u32, + + /// Terminal slot number + pub terminal_slot: u64, + + /// Last L1 height observed + pub last_l1_height: u64, + + /// Terminal block ID + pub terminal_blkid: Bytes32, + + /// Last L1 block ID observed + pub last_l1_blkid: Bytes32, + + /// Final state root + pub final_state_root: Bytes32, +} + +#[cfg(test)] +mod tests { + use ssz::{Decode, Encode}; + + use super::*; + + #[test] + fn test_checkpoint_header_ssz_roundtrip() { + let header = CheckpointHeader { + epoch: 42, + l1_view_update_height: 1000, + terminal_slot: 5000, + terminal_blkid: [1u8; 32], + final_state_root: [2u8; 32], + }; + + let encoded = header.as_ssz_bytes(); + let decoded = CheckpointHeader::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(header, decoded); + } + + #[test] + fn test_epoch_summary_ssz_roundtrip() { + let summary = EpochSummary { + epoch_idx: 10, + terminal_slot: 1000, + last_l1_height: 500, + terminal_blkid: [3u8; 32], + last_l1_blkid: [4u8; 32], + final_state_root: [5u8; 32], + }; + + let encoded = summary.as_ssz_bytes(); + let decoded = EpochSummary::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(summary, decoded); + } + + #[test] + fn test_checkpoint_payload_with_optional_fields() { + let payload = CheckpointPayload { + ol_state_diff: Some(VariableList::from(vec![1, 2, 3, 4])), + packed_output_msgs: None, + }; + + let encoded = payload.as_ssz_bytes(); + let decoded = CheckpointPayload::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(payload, decoded); + } + + #[test] + fn test_checkpoint_data_ssz_roundtrip() { + let data = CheckpointData { + header: CheckpointHeader { + epoch: 1, + l1_view_update_height: 100, + terminal_slot: 200, + terminal_blkid: [6u8; 32], + final_state_root: [7u8; 32], + }, + payload: CheckpointPayload { + ol_state_diff: Some(VariableList::from(vec![10, 20, 30])), + packed_output_msgs: Some(VariableList::from(vec![40, 50])), + }, + ol_state_diff: VariableList::from(vec![10, 20, 30]), + }; + + let encoded = data.as_ssz_bytes(); + let decoded = CheckpointData::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(data, decoded); + } + + #[test] + fn test_checkpoint_ssz_roundtrip() { + let checkpoint = Checkpoint { + data: CheckpointData { + header: CheckpointHeader { + epoch: 2, + l1_view_update_height: 200, + terminal_slot: 400, + terminal_blkid: [8u8; 32], + final_state_root: [9u8; 32], + }, + payload: CheckpointPayload { + ol_state_diff: None, + packed_output_msgs: Some(VariableList::from(vec![60, 70, 80])), + }, + ol_state_diff: VariableList::from(vec![]), + }, + proof: VariableList::from(vec![1, 2, 3, 4, 5]), + }; + + let encoded = checkpoint.as_ssz_bytes(); + let decoded = Checkpoint::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(checkpoint, decoded); + } + + #[test] + fn test_signed_checkpoint_ssz_roundtrip() { + let signed = SignedCheckpoint { + sig: [10u8; 64], + data: Checkpoint { + data: CheckpointData { + header: CheckpointHeader { + epoch: 3, + l1_view_update_height: 300, + terminal_slot: 600, + terminal_blkid: [12u8; 32], + final_state_root: [13u8; 32], + }, + payload: CheckpointPayload { + ol_state_diff: Some(VariableList::from(vec![90, 100])), + packed_output_msgs: None, + }, + ol_state_diff: VariableList::from(vec![90, 100]), + }, + proof: VariableList::from(vec![6, 7, 8]), + }, + }; + + let encoded = signed.as_ssz_bytes(); + let decoded = SignedCheckpoint::from_ssz_bytes(&encoded).unwrap(); + + assert_eq!(signed, decoded); + } + + #[test] + fn test_tree_hash() { + use tree_hash::TreeHash; + + let header = CheckpointHeader { + epoch: 42, + l1_view_update_height: 1000, + terminal_slot: 5000, + terminal_blkid: [1u8; 32], + final_state_root: [2u8; 32], + }; + + // Should not panic and should produce a 32-byte hash + let hash = header.tree_hash_root(); + assert_eq!(hash.0.len(), 32); + } +}