diff --git a/.github/workflows/lez-compat-improved.yml b/.github/workflows/lez-compat-improved.yml index 146bb6d..914ed58 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: | diff --git a/spel-client-gen/src/codegen.rs b/spel-client-gen/src/codegen.rs index 1abcae4..1cf9b20 100644 --- a/spel-client-gen/src/codegen.rs +++ b/spel-client-gen/src/codegen.rs @@ -204,8 +204,16 @@ 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 3bbca08..0524689 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,13 @@ 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 5e0ecb3..7eb0df9 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 85d7eee..6bb6a58 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,44 @@ 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 dd57c46..1592d37 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 eb0592e..74d1afd 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,23 @@ 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. + // 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 { name: param_name, @@ -625,6 +645,7 @@ fn parse_instruction(func: ItemFn) -> Result { fn_name, accounts, args, + has_clock_context, }) } @@ -647,6 +668,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 922067c..c93e678 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 b9c9d25..817cb05 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 39c0bb0..4af5537 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,41 @@ 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, + // 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 { + 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); + // 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) + }; + } + } else { + quote! {} + }; + let call_args: Vec = { let mut args: Vec = Vec::new(); // Context is always first if present (enforced by parse_instruction). @@ -860,6 +927,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 +942,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 +1043,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 +1942,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 +1954,7 @@ fn generate_idl_fn( private_owned: false, }), variant: Some(#variant_name_str.to_string()), + has_clock_context: #has_clock, } } }) @@ -1984,11 +2078,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();