Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions .github/workflows/lez-compat-improved.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
10 changes: 9 additions & 1 deletion spel-client-gen/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,16 @@ pub fn generate_client(idl: &SpelIdl) -> Result<String, String> {
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<AccountId> = 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();
}
Expand Down
11 changes: 10 additions & 1 deletion spel-client-gen/src/ffi_codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -450,14 +450,23 @@ pub fn generate_ffi(idl: &SpelIdl, idl_json: &str) -> Result<String, String> {
}
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!(
out,
" {account_ids_binding} account_ids: Vec<AccountId> = 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();
}
Expand Down
5 changes: 5 additions & 0 deletions spel-client-gen/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
Expand Down Expand Up @@ -345,6 +346,7 @@ fn test_pda_helpers_multi_seed() {
discriminator: None,
execution: None,
variant: None,
has_clock_context: false,
}],
accounts: vec![],
types: vec![],
Expand Down Expand Up @@ -421,6 +423,7 @@ fn test_pda_helpers_deduplication() {
discriminator: None,
execution: None,
variant: None,
has_clock_context: false,
};

let idl = SpelIdl {
Expand Down Expand Up @@ -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![],
Expand Down Expand Up @@ -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![],
Expand Down
43 changes: 42 additions & 1 deletion spel-framework-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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,
}
}
}
6 changes: 6 additions & 0 deletions spel-framework-core/src/idl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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.
Expand Down
30 changes: 30 additions & 0 deletions spel-framework-core/src/idl_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ fn generate_idl_inner(
discriminator: None,
execution: None,
variant: None,
has_clock_context: ix.has_clock_context,
}
})
.collect();
Expand Down Expand Up @@ -546,6 +547,7 @@ struct InstructionInfo {
fn_name: Ident,
accounts: Vec<AccountParam>,
args: Vec<ArgParam>,
has_clock_context: bool,
}

struct AccountParam {
Expand Down Expand Up @@ -582,6 +584,7 @@ fn parse_instruction(func: ItemFn) -> Result<InstructionInfo, IdlGenError> {
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 {
Expand All @@ -605,6 +608,23 @@ fn parse_instruction(func: ItemFn) -> Result<InstructionInfo, IdlGenError> {
});
} 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;
Comment on lines 609 to +627
} else {
args.push(ArgParam {
name: param_name,
Expand All @@ -625,6 +645,7 @@ fn parse_instruction(func: ItemFn) -> Result<InstructionInfo, IdlGenError> {
fn_name,
accounts,
args,
has_clock_context,
})
}

Expand All @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion spel-framework-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
63 changes: 59 additions & 4 deletions spel-framework-core/tests/context_parameter.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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)");
}
Loading
Loading