From ca6385cf1c39dd43bab9fde43abb686e4dd02e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Mon, 1 Jun 2026 10:06:58 +0200 Subject: [PATCH 1/4] feat(context): add ClockContext for opt-in timestamp/block_id access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ClockContext` as a new injected parameter type for `#[instruction]` handlers, giving programs access to the current block number and timestamp without adding them to the instruction ABI or IDL. The dispatcher reads the LEZ clock account (`/LEZ/ClockProgramAccount/0000001`) from `pre_states[0]`, borsh-decodes it into `ClockContext`, and injects it into the handler call — exactly like `ProgramContext`. Transaction builders (ffi_codegen, codegen, logos_module_codegen) automatically prepend the clock account ID when `has_clock_context` is set in the IDL. Closes #226 (option 1 of the design discussion). Co-Authored-By: Claude Sonnet 4.6 --- spel-client-gen/src/codegen.rs | 6 +- spel-client-gen/src/ffi_codegen.rs | 7 +- spel-client-gen/src/tests.rs | 5 + spel-framework-core/src/context.rs | 40 +++++++- spel-framework-core/src/idl.rs | 6 ++ spel-framework-core/src/idl_gen.rs | 16 ++++ spel-framework-core/src/lib.rs | 2 +- .../tests/context_parameter.rs | 63 ++++++++++++- spel-framework-macros/src/lib.rs | 92 ++++++++++++++++++- 9 files changed, 226 insertions(+), 11 deletions(-) diff --git a/spel-client-gen/src/codegen.rs b/spel-client-gen/src/codegen.rs index 1abcae43..478cc822 100644 --- a/spel-client-gen/src/codegen.rs +++ b/spel-client-gen/src/codegen.rs @@ -204,8 +204,12 @@ pub fn generate_client(idl: &SpelIdl) -> Result { writeln!(out, " }};").unwrap(); } - // Account IDs + // Account IDs. When the instruction uses ClockContext, prepend the LEZ clock + // account so it lands at pre_states[0] as the dispatcher expects. writeln!(out, " let mut account_ids: Vec = vec![").unwrap(); + if ix.has_clock_context { + writeln!(out, " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\"),").unwrap(); + } for acc in ix.accounts.iter().filter(|a| !a.rest) { writeln!(out, " accounts.{},", rust_ident(&acc.name)).unwrap(); } diff --git a/spel-client-gen/src/ffi_codegen.rs b/spel-client-gen/src/ffi_codegen.rs index 3bbca085..042e6c48 100644 --- a/spel-client-gen/src/ffi_codegen.rs +++ b/spel-client-gen/src/ffi_codegen.rs @@ -450,7 +450,9 @@ pub fn generate_ffi(idl: &SpelIdl, idl_json: &str) -> Result { } writeln!(out).unwrap(); - // Build account_ids vec (non-rest accounts first, then rest) + // Build account_ids vec (non-rest accounts first, then rest). + // When the instruction uses ClockContext, prepend the LEZ clock account so it + // lands at pre_states[0] as the dispatcher expects. let has_rest = ix.accounts.iter().any(|a| a.rest); let account_ids_binding = if has_rest { "let mut" } else { "let" }; writeln!( @@ -458,6 +460,9 @@ pub fn generate_ffi(idl: &SpelIdl, idl_json: &str) -> Result { " {account_ids_binding} account_ids: Vec = vec![" ) .unwrap(); + if ix.has_clock_context { + writeln!(out, " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\"),").unwrap(); + } for acc in ix.accounts.iter().filter(|a| !a.rest) { writeln!(out, " {},", rust_ident(&acc.name)).unwrap(); } diff --git a/spel-client-gen/src/tests.rs b/spel-client-gen/src/tests.rs index 5e0ecb32..7eb0df93 100644 --- a/spel-client-gen/src/tests.rs +++ b/spel-client-gen/src/tests.rs @@ -262,6 +262,7 @@ fn test_pda_helpers_single_arg_seed() { discriminator: None, execution: None, variant: None, + has_clock_context: false, }], accounts: vec![], types: vec![], @@ -345,6 +346,7 @@ fn test_pda_helpers_multi_seed() { discriminator: None, execution: None, variant: None, + has_clock_context: false, }], accounts: vec![], types: vec![], @@ -421,6 +423,7 @@ fn test_pda_helpers_deduplication() { discriminator: None, execution: None, variant: None, + has_clock_context: false, }; let idl = SpelIdl { @@ -500,6 +503,7 @@ fn test_pda_helpers_u64_single_seed() { discriminator: None, execution: None, variant: None, + has_clock_context: false, }], accounts: vec![], types: vec![], @@ -588,6 +592,7 @@ fn test_pda_helpers_u64_multi_seed() { discriminator: None, execution: None, variant: None, + has_clock_context: false, }], accounts: vec![], types: vec![], diff --git a/spel-framework-core/src/context.rs b/spel-framework-core/src/context.rs index 85d7eeeb..76f16baa 100644 --- a/spel-framework-core/src/context.rs +++ b/spel-framework-core/src/context.rs @@ -5,7 +5,7 @@ //! values from [`nssa_core::program::ProgramInput`] at call time. //! The context parameter is **never** part of the instruction ABI or IDL. -use crate::prelude::ProgramId; +use crate::prelude::{BlockId, ProgramId, Timestamp}; /// Trusted execution metadata supplied by the SPEL guest entrypoint. /// @@ -43,3 +43,41 @@ impl ProgramContext { } } } + +/// Clock metadata injected by the SPEL dispatcher when an `#[instruction]` handler +/// declares a `ClockContext` parameter. +/// +/// Use this to access the current block number and timestamp without adding +/// them to the instruction schema. Like [`ProgramContext`], it is **never** +/// part of the instruction ABI or IDL. The dispatcher reads it from the +/// LEZ clock account (`/LEZ/ClockProgramAccount/0000001`), which the +/// transaction builder automatically prepends to the account list. +/// +/// ```ignore +/// #[instruction] +/// pub fn time_locked_transfer( +/// ctx: ProgramContext, +/// clock: ClockContext, +/// #[account(owner = self_program_id)] +/// vault: AccountWithMetadata, +/// unlock_timestamp: u64, +/// ) -> SpelResult { +/// if clock.timestamp < unlock_timestamp { +/// return Err(SpelError::new(1, "still locked")); +/// } +/// } +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, borsh::BorshDeserialize)] +pub struct ClockContext { + /// The block number at the time of execution. + pub block_id: BlockId, + /// The block timestamp in milliseconds since the Unix epoch. + pub timestamp: Timestamp, +} + +impl ClockContext { + #[must_use] + pub const fn new(block_id: BlockId, timestamp: Timestamp) -> Self { + Self { block_id, timestamp } + } +} diff --git a/spel-framework-core/src/idl.rs b/spel-framework-core/src/idl.rs index dd57c465..1592d37e 100644 --- a/spel-framework-core/src/idl.rs +++ b/spel-framework-core/src/idl.rs @@ -71,6 +71,12 @@ pub struct IdlInstruction { /// Variant name in PascalCase (lssa-lang compat). #[serde(default, skip_serializing_if = "Option::is_none")] pub variant: Option, + /// True when the handler declares a `ClockContext` parameter. + /// The dispatcher reads the LEZ clock account (`/LEZ/ClockProgramAccount/0000001`) + /// from `pre_states[0]` and injects it; transaction builders must prepend that + /// account to the account list. Never appears in the instruction ABI or IDL args. + #[serde(default, skip_serializing_if = "is_false")] + pub has_clock_context: bool, } /// An account expected by an instruction. diff --git a/spel-framework-core/src/idl_gen.rs b/spel-framework-core/src/idl_gen.rs index eb0592e6..9aa285a5 100644 --- a/spel-framework-core/src/idl_gen.rs +++ b/spel-framework-core/src/idl_gen.rs @@ -210,6 +210,7 @@ fn generate_idl_inner( discriminator: None, execution: None, variant: None, + has_clock_context: ix.has_clock_context, } }) .collect(); @@ -546,6 +547,7 @@ struct InstructionInfo { fn_name: Ident, accounts: Vec, args: Vec, + has_clock_context: bool, } struct AccountParam { @@ -582,6 +584,7 @@ fn parse_instruction(func: ItemFn) -> Result { let fn_name = func.sig.ident.clone(); let mut accounts = Vec::new(); let mut args = Vec::new(); + let mut has_clock_context = false; for input in &func.sig.inputs { match input { @@ -605,6 +608,9 @@ fn parse_instruction(func: ItemFn) -> Result { }); } else if is_context_type(ty) { // ProgramContext is injected by the dispatcher and never part of the IDL/ABI. + } else if is_clock_context_type(ty) { + // ClockContext is injected by the dispatcher and never part of the IDL/ABI. + has_clock_context = true; } else { args.push(ArgParam { name: param_name, @@ -625,6 +631,7 @@ fn parse_instruction(func: ItemFn) -> Result { fn_name, accounts, args, + has_clock_context, }) } @@ -647,6 +654,15 @@ fn is_context_type(ty: &Type) -> bool { false } +fn is_clock_context_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "ClockContext"; + } + } + false +} + fn is_account_type(ty: &Type) -> bool { if let Type::Path(type_path) = ty { if let Some(segment) = type_path.path.segments.last() { diff --git a/spel-framework-core/src/lib.rs b/spel-framework-core/src/lib.rs index 922067c7..c93e678e 100644 --- a/spel-framework-core/src/lib.rs +++ b/spel-framework-core/src/lib.rs @@ -39,7 +39,7 @@ pub mod prelude { pub use nssa_core::program::{read_nssa_inputs, InstructionData, ProgramInput, ProgramOutput}; // Execution context for instruction handlers (issue #172) - pub use crate::context::ProgramContext; + pub use crate::context::{ClockContext, ProgramContext}; // nssa::public_transaction (host-only) #[cfg(feature = "host")] diff --git a/spel-framework-core/tests/context_parameter.rs b/spel-framework-core/tests/context_parameter.rs index b9c9d257..817cb050 100644 --- a/spel-framework-core/tests/context_parameter.rs +++ b/spel-framework-core/tests/context_parameter.rs @@ -1,10 +1,10 @@ -//! Test that ProgramContext is properly handled by the framework. +//! Test that ProgramContext and ClockContext are properly handled by the framework. //! -//! This tests the contract: instruction handlers can declare a ProgramContext -//! parameter and receive trusted execution metadata without polluting the IDL. +//! This tests the contract: instruction handlers can declare ProgramContext and/or +//! ClockContext parameters and receive trusted execution metadata without polluting the IDL. use nssa_core::program::ProgramId; -use spel_framework_core::context::ProgramContext; +use spel_framework_core::context::{ClockContext, ProgramContext}; /// Verify ProgramContext can be constructed and accessed. #[test] @@ -116,3 +116,58 @@ fn test_handler_uses_caller_program_id() { assert!(is_authorized_caller(&ctx, caller_program)); assert!(!is_authorized_caller(&ctx, [99u32; 8])); } + +// ── ClockContext tests ──────────────────────────────────────────────────────── + +#[test] +fn test_clock_context_construction() { + let clock = ClockContext::new(42, 1_700_000_000_000); + assert_eq!(clock.block_id, 42); + assert_eq!(clock.timestamp, 1_700_000_000_000); +} + +#[test] +fn test_clock_context_clone_copy() { + let clock = ClockContext::new(10, 999); + let clock2 = clock; + let clock3 = clock.clone(); + assert_eq!(clock.block_id, clock2.block_id); + assert_eq!(clock.block_id, clock3.block_id); +} + +#[test] +fn test_clock_context_equality() { + let a = ClockContext::new(1, 100); + let b = ClockContext::new(1, 100); + let c = ClockContext::new(2, 200); + assert_eq!(a, b); + assert_ne!(a, c); +} + +#[test] +fn test_clock_context_debug() { + let clock = ClockContext::new(7, 42); + let s = format!("{:?}", clock); + assert!(s.contains("ClockContext")); +} + +#[test] +fn test_clock_context_borsh_roundtrip() { + use borsh::{BorshDeserialize, BorshSerialize}; + + // ClockAccountData layout: block_id (u64 LE) then timestamp (u64 LE) + let mut bytes = Vec::new(); + 99u64.serialize(&mut bytes).unwrap(); + 1_234_567_890_000u64.serialize(&mut bytes).unwrap(); + + let clock = ClockContext::try_from_slice(&bytes).expect("borsh decode should succeed"); + assert_eq!(clock.block_id, 99); + assert_eq!(clock.timestamp, 1_234_567_890_000); +} + +#[test] +fn test_clock_context_borsh_wrong_length_fails() { + use borsh::BorshDeserialize; + let result = ClockContext::try_from_slice(&[0u8; 4]); + assert!(result.is_err(), "decoding 4 bytes should fail (need 16)"); +} diff --git a/spel-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs index 39c0bb0c..1a4f8399 100644 --- a/spel-framework-macros/src/lib.rs +++ b/spel-framework-macros/src/lib.rs @@ -166,6 +166,11 @@ struct InstructionInfo { /// True if this instruction has a ProgramContext parameter. /// The context is injected by the dispatcher and never appears in IDL/ABI. has_context: bool, + /// True if this instruction has a ClockContext parameter. + /// The dispatcher reads the clock account from pre_states[0] and injects it; + /// it never appears in the IDL/ABI. Transaction builders must prepend the + /// clock account (`/LEZ/ClockProgramAccount/0000001`) to the account list. + has_clock_context: bool, /// The original function item (with #[instruction] stripped) func: ItemFn, } @@ -497,6 +502,7 @@ fn parse_instruction(func: ItemFn) -> syn::Result { let mut accounts = Vec::new(); let mut args = Vec::new(); let mut has_context = false; + let mut has_clock_context = false; for (idx, input) in func.sig.inputs.iter().enumerate() { match input { @@ -533,6 +539,21 @@ fn parse_instruction(func: ItemFn) -> syn::Result { )); } has_context = true; + } else if is_clock_context_type(ty) { + // ClockContext — injected by dispatcher from the clock pre_state, not part of ABI/IDL. + if has_clock_context { + return Err(syn::Error::new_spanned( + ty, + "instruction functions can have at most one ClockContext parameter", + )); + } + if !accounts.is_empty() || !args.is_empty() { + return Err(syn::Error::new_spanned( + ty, + "ClockContext must appear before any account or arg parameters", + )); + } + has_clock_context = true; } else { args.push(ArgParam { name: param_name, @@ -554,6 +575,7 @@ fn parse_instruction(func: ItemFn) -> syn::Result { accounts, args, has_context, + has_clock_context, func, }) } @@ -603,6 +625,16 @@ fn is_context_type(ty: &Type) -> bool { false } +/// Check if a type is ClockContext (clock context injected from the clock pre_state). +fn is_clock_context_type(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + if let Some(segment) = type_path.path.segments.last() { + return segment.ident == "ClockContext"; + } + } + false +} + fn parse_account_constraints(attrs: &[Attribute]) -> syn::Result { let mut constraints = AccountConstraints::default(); @@ -848,6 +880,27 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve }); let validate_fn_name = format_ident!("__validate_{}", ix.fn_name); + // When ClockContext is present, strip pre_states[0] as the clock account, + // decode it into ClockContext, and rebind `pre_states` to the remainder. + // The original pre_states[0] is saved so its post_state can be reinserted + // at position 0 after the handler returns, keeping the zip in main() aligned. + let clock_setup = if ix.has_clock_context { + quote! { + let (__clock_account_pre, __clock_context, pre_states) = { + let mut __ps = pre_states; + if __ps.is_empty() { + panic!("SPEL ClockContext: expected clock account as first pre_state, found none"); + } + let __c = __ps.remove(0); + let __ctx = borsh::from_slice::(&__c.account.data) + .expect("SPEL ClockContext: failed to decode clock account data"); + (__c, __ctx, __ps) + }; + } + } else { + quote! {} + }; + let call_args: Vec = { let mut args: Vec = Vec::new(); // Context is always first if present (enforced by parse_instruction). @@ -860,6 +913,10 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve ) }); } + // ClockContext comes after ProgramContext (enforced by parse_instruction). + if ix.has_clock_context { + args.push(quote! { __clock_context }); + } args.extend(ix.accounts.iter().map(|a| { let name = &a.name; quote! { #name } @@ -871,6 +928,26 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve args }; + // Re-insert the clock account as an unchanged post_state at position 0 so + // that pre_states_clone[0] (the clock account) and post_states[0] align in + // the zip used to build ProgramOutput. + let map_output = if ix.has_clock_context { + quote! { + .map(|output| { + let mut __parts = output.into_parts(); + __parts.post_states.insert( + 0, + nssa_core::program::AccountPostState::new( + __clock_account_pre.account.clone() + ), + ); + __parts + }) + } + } else { + quote! { .map(|output| output.into_parts()) } + }; + // Collect arg seed values to pass to validation let arg_seed_values: Vec = { let mut names = Vec::new(); @@ -952,10 +1029,11 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve quote! { #pattern => { + #clock_setup #account_destructure #validation_call #mod_name::#fn_name(#(#call_args),*) - .map(|output| output.into_parts()) + #map_output } } }) @@ -1850,6 +1928,7 @@ fn generate_idl_fn( .collect::() }; + let has_clock = ix.has_clock_context; quote! { spel_framework::idl::IdlInstruction { name: #ix_name.to_string(), @@ -1861,6 +1940,7 @@ fn generate_idl_fn( private_owned: false, }), variant: Some(#variant_name_str.to_string()), + has_clock_context: #has_clock, } } }) @@ -1984,11 +2064,17 @@ fn generate_idl_json( }) .collect(); + let clock_json = if ix.has_clock_context { + ",\"has_clock_context\":true".to_string() + } else { + String::new() + }; format!( - "{{\"name\":\"{}\",\"accounts\":[{}],\"args\":[{}]}}", + "{{\"name\":\"{}\",\"accounts\":[{}],\"args\":[{}]{}}}", ix_name, accounts_json.join(","), - args_json.join(",") + args_json.join(","), + clock_json, ) }) .collect(); From d817125c00682325e94128d07892204855812c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Mon, 1 Jun 2026 10:33:14 +0200 Subject: [PATCH 2/4] ci: remove obsolete logos-blockchain-circuits install step The setup-logos-blockchain-circuits.sh script was removed from the logos-blockchain/logos-blockchain repo on 2026-05-30 (commit 89259924) in "chore: Purge old circuits (#2829)". The curl call now fetches a 404 page and bash interprets "404: Not Found" as a command, causing all E2E jobs to fail immediately with exit code 127. The step was never needed for CI: the sequencer is built with `--features standalone`, which activates `sequencer_core/mock` and replaces actual ZK proof generation with mock implementations. Circuits are only required for production proving. Also removes two duplicate occurrences of the step in the privacy-smoke-test and ffi-call-test jobs. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 53 ----------------------- .github/workflows/lez-compat-improved.yml | 10 ----- 2 files changed, 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d288410..07bad84d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,11 +32,6 @@ jobs: - uses: Swatinem/rust-cache@v2 with: prefix-key: "e2e" - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build (all packages including spel) run: cargo build -p spel-framework -p spel-framework-core -p spel-framework-macros -p spel-client-gen -p spel - name: E2E tests @@ -82,12 +77,6 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Clone LEZ at correct revision run: | rm -rf /tmp/lssa @@ -187,18 +176,6 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: @@ -223,12 +200,6 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run privacy smoke test env: LSSA_DIR: /tmp/lssa @@ -271,18 +242,6 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: @@ -307,12 +266,6 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Run FFI call test env: LSSA_DIR: /tmp/lssa @@ -355,12 +308,6 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} - - name: Install logos-blockchain-circuits - run: | - curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: diff --git a/.github/workflows/lez-compat-improved.yml b/.github/workflows/lez-compat-improved.yml index 146bb6da..914ed584 100644 --- a/.github/workflows/lez-compat-improved.yml +++ b/.github/workflows/lez-compat-improved.yml @@ -253,16 +253,6 @@ jobs: sudo apt-get update sudo apt-get install -y pkg-config libssl-dev cmake - - name: Install logos-blockchain-circuits - run: | - if [ -f scripts/setup-logos-blockchain-circuits.sh ]; then - bash scripts/setup-logos-blockchain-circuits.sh - else - echo "Setup script not found, skipping..." - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Cargo metadata (dependency analysis) id: deps run: | From c948c1d38016ae3fefdee6a0f5ea1397d19ae3ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Mon, 1 Jun 2026 12:17:30 +0200 Subject: [PATCH 3/4] fix: rustfmt violations and restore circuits install in ci.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply rustfmt: expand single-line writeln! calls and struct literal in ClockContext::new to conform to the project's 100-char line limit - Restore the logos-blockchain-circuits install step in ci.yml with the pinned commit hash; removing it was wrong — cargo build -p spel triggers the circuits build.rs check even in tests Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 53 ++++++++++++++++++++++++++++++ spel-client-gen/src/codegen.rs | 6 +++- spel-client-gen/src/ffi_codegen.rs | 6 +++- spel-framework-core/src/context.rs | 5 ++- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07bad84d..9d288410 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,11 @@ jobs: - uses: Swatinem/rust-cache@v2 with: prefix-key: "e2e" + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build (all packages including spel) run: cargo build -p spel-framework -p spel-framework-core -p spel-framework-macros -p spel-client-gen -p spel - name: E2E tests @@ -77,6 +82,12 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Clone LEZ at correct revision run: | rm -rf /tmp/lssa @@ -176,6 +187,18 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: @@ -200,6 +223,12 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run privacy smoke test env: LSSA_DIR: /tmp/lssa @@ -242,6 +271,18 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: @@ -266,6 +307,12 @@ jobs: rzup install cargo-risczero ${{ env.RISC0_VERSION }} rzup install rust + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run FFI call test env: LSSA_DIR: /tmp/lssa @@ -308,6 +355,12 @@ jobs: path: /tmp/lssa/target/release/wallet key: wallet-${{ needs.sequencer-setup.outputs.lez-rev }} + - name: Install logos-blockchain-circuits + run: | + curl -sSL https://raw.githubusercontent.com/logos-blockchain/logos-blockchain/1da154c74b911318fb853d37261f8a05ffe513b4/scripts/setup-logos-blockchain-circuits.sh | bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Restore spel CLI cache (PR-specific) uses: actions/cache@v4 with: diff --git a/spel-client-gen/src/codegen.rs b/spel-client-gen/src/codegen.rs index 478cc822..1cf9b201 100644 --- a/spel-client-gen/src/codegen.rs +++ b/spel-client-gen/src/codegen.rs @@ -208,7 +208,11 @@ pub fn generate_client(idl: &SpelIdl) -> Result { // account so it lands at pre_states[0] as the dispatcher expects. writeln!(out, " let mut account_ids: Vec = vec![").unwrap(); if ix.has_clock_context { - writeln!(out, " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\"),").unwrap(); + writeln!( + out, + " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\")," + ) + .unwrap(); } for acc in ix.accounts.iter().filter(|a| !a.rest) { writeln!(out, " accounts.{},", rust_ident(&acc.name)).unwrap(); diff --git a/spel-client-gen/src/ffi_codegen.rs b/spel-client-gen/src/ffi_codegen.rs index 042e6c48..05246896 100644 --- a/spel-client-gen/src/ffi_codegen.rs +++ b/spel-client-gen/src/ffi_codegen.rs @@ -461,7 +461,11 @@ pub fn generate_ffi(idl: &SpelIdl, idl_json: &str) -> Result { ) .unwrap(); if ix.has_clock_context { - writeln!(out, " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\"),").unwrap(); + writeln!( + out, + " AccountId::new(*b\"/LEZ/ClockProgramAccount/0000001\")," + ) + .unwrap(); } for acc in ix.accounts.iter().filter(|a| !a.rest) { writeln!(out, " {},", rust_ident(&acc.name)).unwrap(); diff --git a/spel-framework-core/src/context.rs b/spel-framework-core/src/context.rs index 76f16baa..6bb6a583 100644 --- a/spel-framework-core/src/context.rs +++ b/spel-framework-core/src/context.rs @@ -78,6 +78,9 @@ pub struct ClockContext { impl ClockContext { #[must_use] pub const fn new(block_id: BlockId, timestamp: Timestamp) -> Self { - Self { block_id, timestamp } + Self { + block_id, + timestamp, + } } } From 5752e896ad76228ca3ac30f598f2b400bf2a83e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=A1clav=20Pavl=C3=ADn?= Date: Mon, 1 Jun 2026 12:21:55 +0200 Subject: [PATCH 4/4] fix: address Copilot review comments - Validate clock account ID before decoding ClockContext: compare pre_states[0].account_id against the well-known LEZ clock account address (*b"/LEZ/ClockProgramAccount/0000001"). Without this, a caller could supply an attacker-controlled account at position 0 to forge timestamp/block_id values seen by the handler. - Mirror proc-macro constraints in idl_gen: reject duplicate ClockContext parameters and ClockContext appearing after account/arg params. The proc-macro already enforces these; idl_gen now matches so that it cannot produce an IDL that claims ClockContext injection for a signature the macro would refuse to compile. Co-Authored-By: Claude Sonnet 4.6 --- spel-framework-core/src/idl_gen.rs | 14 ++++++++++++++ spel-framework-macros/src/lib.rs | 20 +++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/spel-framework-core/src/idl_gen.rs b/spel-framework-core/src/idl_gen.rs index 9aa285a5..74d1afdb 100644 --- a/spel-framework-core/src/idl_gen.rs +++ b/spel-framework-core/src/idl_gen.rs @@ -610,6 +610,20 @@ fn parse_instruction(func: ItemFn) -> Result { // ProgramContext is injected by the dispatcher and never part of the IDL/ABI. } else if is_clock_context_type(ty) { // ClockContext is injected by the dispatcher and never part of the IDL/ABI. + // Mirror the proc-macro constraints: at most one ClockContext, and it must + // appear before any account or arg parameters. + if has_clock_context { + return Err(IdlGenError::Parse(syn::Error::new_spanned( + ty, + "instruction functions can have at most one ClockContext parameter", + ))); + } + if !accounts.is_empty() || !args.is_empty() { + return Err(IdlGenError::Parse(syn::Error::new_spanned( + ty, + "ClockContext must appear before any account or arg parameters", + ))); + } has_clock_context = true; } else { args.push(ArgParam { diff --git a/spel-framework-macros/src/lib.rs b/spel-framework-macros/src/lib.rs index 1a4f8399..4af5537e 100644 --- a/spel-framework-macros/src/lib.rs +++ b/spel-framework-macros/src/lib.rs @@ -881,7 +881,8 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve let validate_fn_name = format_ident!("__validate_{}", ix.fn_name); // When ClockContext is present, strip pre_states[0] as the clock account, - // decode it into ClockContext, and rebind `pre_states` to the remainder. + // validate its account_id against the well-known LEZ clock address, decode + // it into ClockContext, and rebind `pre_states` to the remainder. // The original pre_states[0] is saved so its post_state can be reinserted // at position 0 after the handler returns, keeping the zip in main() aligned. let clock_setup = if ix.has_clock_context { @@ -892,8 +893,21 @@ fn generate_match_arms(mod_name: &Ident, instructions: &[InstructionInfo]) -> Ve panic!("SPEL ClockContext: expected clock account as first pre_state, found none"); } let __c = __ps.remove(0); - let __ctx = borsh::from_slice::(&__c.account.data) - .expect("SPEL ClockContext: failed to decode clock account data"); + // Reject pre_states[0] that isn't the expected LEZ clock account. + // Without this check a caller could forge timestamp/block_id by + // supplying an attacker-controlled account at position 0. + const __EXPECTED_CLOCK_ID: nssa_core::account::AccountId = + nssa_core::account::AccountId::new(*b"/LEZ/ClockProgramAccount/0000001"); + if __c.account_id != __EXPECTED_CLOCK_ID { + panic!( + "SPEL ClockContext: pre_states[0] is not the LEZ clock account \ + (expected /LEZ/ClockProgramAccount/0000001)" + ); + } + let __ctx = borsh::from_slice::( + &__c.account.data, + ) + .expect("SPEL ClockContext: failed to decode clock account data"); (__c, __ctx, __ps) }; }