Background
LEZ (Logos Execution Zone) does not expose the current timestamp or block number directly in ProgramInput. Instead, LEZ maintains a dedicated Clock Program whose accounts are updated by the sequencer at every block. Each clock account stores:
// nssa/programs/clock/core/src/lib.rs
pub struct ClockAccountData {
pub block_id: u64,
pub timestamp: Timestamp, // u64, milliseconds since epoch
}
Three accounts exist, updated at different cadences:
CLOCK_01_PROGRAM_ACCOUNT_ID — every block
CLOCK_10_PROGRAM_ACCOUNT_ID — every 10 blocks
CLOCK_50_PROGRAM_ACCOUNT_ID — every 50 blocks
Currently, ProgramContext (the injected execution context available to instruction handlers) only exposes program identity:
pub struct ProgramContext {
pub self_program_id: ProgramId,
pub caller_program_id: ProgramId,
}
There is no way to access the current time or block height from within an instruction handler without manually declaring the clock account as an explicit AccountWithMetadata parameter.
Problem
Writing time-aware instructions (time locks, expiry checks, cooldowns, rate limits) is verbose and error-prone today. Developers must:
- Manually declare a clock account parameter
- Manually decode
ClockAccountData from raw bytes
- Manually validate that the account is actually the clock account (or skip validation entirely)
No LEZ protocol changes are needed — both proposed options work by reading from the existing clock account inside pre_states. The question is entirely about SPEL developer ergonomics.
Option 1 — Explicit ClockContext parameter (opt-in per instruction)
A new ClockContext type that the macro recognises by name, similar to ProgramContext. The developer opts in per instruction by declaring it as the second parameter. SPEL handles reading and decoding the clock account from pre_states automatically; it never appears in the IDL or ABI.
Developer experience
#[instruction]
pub fn time_locked_transfer(
ctx: ProgramContext,
clock: ClockContext, // opt-in; injected by dispatcher, not in IDL/ABI
#[account(owner = self_program_id)]
vault: AccountWithMetadata,
recipient: AccountWithMetadata,
unlock_timestamp: u64,
) -> SpelResult {
if clock.timestamp < unlock_timestamp {
return Err(SpelError::new(1, "transfer is still time-locked"));
}
if clock.block_id < required_block {
return Err(SpelError::new(2, "block not yet reached"));
}
// ...
}
// Instructions that don't need time are completely unaffected
#[instruction]
pub fn simple_transfer(
ctx: ProgramContext,
vault: AccountWithMetadata,
recipient: AccountWithMetadata,
amount: u64,
) -> SpelResult { /* no clock overhead */ }
The ClockContext struct:
pub struct ClockContext {
pub block_id: BlockId, // u64
pub timestamp: Timestamp, // u64, milliseconds since epoch
}
How it works at runtime
- Macro detects
ClockContext parameter (new is_clock_context_type() check, like the existing is_context_type())
- Dispatcher reads
pre_states[0] as the clock account, validates its account ID, decodes ClockAccountData
- Constructs
ClockContext and passes it as an argument — never appears in the instruction enum or IDL
- Regular accounts continue at
pre_states[1..]
has_clock_context: bool is added to IdlInstruction so generated clients know to prepend the clock account when building transactions
Component change map
| Component |
File |
Change |
Breaking? |
| spel-framework-core |
src/context.rs |
Add ClockContext struct + new() |
No |
| spel-framework-core |
src/lib.rs |
Re-export ClockContext from prelude |
No |
| spel-framework-core |
src/idl.rs |
Add has_clock_context: bool to IdlInstruction (optional, skipped when false) |
No |
| spel-framework-macros |
src/lib.rs |
Add is_clock_context_type(), parse new parameter type, update InstructionInfo, inject ClockContext in match-arm codegen, read pre_states[0] as clock when present, emit has_clock_context in IDL codegen |
No |
| spel-client-gen |
src/codegen.rs |
When ix.has_clock_context, prepend CLOCK_01_PROGRAM_ACCOUNT_ID to the account list before building pre-states |
No |
| spel-client-gen |
src/ffi_codegen.rs |
Same — prepend clock account to the resolved account_ids vec in the two-pass account resolver |
No |
| spel-client-gen |
src/logos_module_codegen.rs |
Same — prepend clock account to the account fetch list |
No |
| spel-framework-core |
tests/context_parameter.rs |
Add new test suite for ClockContext injection |
No |
| tests/e2e |
fixture_program/src/lib.rs |
Add clock context test instruction (additive) |
No |
No breaking changes. All existing programs compile and behave identically.
Option 2 — Extend ProgramContext (always available)
ProgramContext gains timestamp and block_id fields. The clock account is always prepended to pre_states by the dispatcher for every instruction. Every instruction gets time for free.
Developer experience
#[instruction]
pub fn time_locked_transfer(
ctx: ProgramContext, // ctx.timestamp and ctx.block_id always available
#[account(owner = self_program_id)]
vault: AccountWithMetadata,
recipient: AccountWithMetadata,
unlock_timestamp: u64,
) -> SpelResult {
if ctx.timestamp < unlock_timestamp {
return Err(SpelError::new(1, "transfer is still time-locked"));
}
// ...
}
The extended struct:
pub struct ProgramContext {
pub self_program_id: ProgramId,
pub caller_program_id: ProgramId,
pub timestamp: Timestamp, // u64, milliseconds since epoch
pub block_id: BlockId, // u64
}
Component change map
| Component |
File |
Change |
Breaking? |
| spel-framework-core |
src/context.rs |
Add timestamp and block_id fields; extend new() signature |
Yes |
| spel-framework-macros |
src/lib.rs |
Update ProgramContext::new(...) call at line 836 to pass 4 args; read clock account from pre_states[0] unconditionally before dispatching |
Yes |
| spel-client-gen |
src/codegen.rs |
Always prepend CLOCK_01_PROGRAM_ACCOUNT_ID to every instruction's account list |
No |
| spel-client-gen |
src/ffi_codegen.rs |
Same — always prepend clock account |
No |
| spel-client-gen |
src/logos_module_codegen.rs |
Same — always prepend clock account |
No |
| spel-framework-core |
tests/context_parameter.rs |
Update all ProgramContext::new([1u32;8], [2u32;8]) calls (×7 occurrences) to pass timestamp and block_id |
Yes |
| tests/e2e |
fixture_program/src/lib.rs |
Update ProgramContext::new(program_id, [2u32;8]) at line 836 |
Yes |
Breaking change: All direct calls to ProgramContext::new() must be updated. Instruction handler function bodies are unaffected (the macro injects the context). The ABI and IDL are unchanged.
Side-by-side comparison
|
Option 1 — ClockContext |
Option 2 — Extend ProgramContext |
| DX |
Explicit opt-in per instruction |
Always available, zero extra syntax |
| Clock overhead per instruction |
Only when ClockContext declared |
Always (even for pure math instructions) |
| Breaking change |
None |
Yes — ProgramContext::new() signature |
| IDL change |
Add optional has_clock_context flag |
None |
| Codegen complexity |
Conditional prepend when flag is set |
Always prepend |
| Discoverability |
Requires knowing ClockContext exists |
Automatic — ctx.timestamp always there |
Consistency with ProgramContext |
New parallel pattern |
Single unified context |
| Scope of macro changes |
New type detection + injection path |
Extend existing injection path |
Open questions for team discussion
-
Which clock account to use? CLOCK_01 (most fresh, updated every block) seems right as the default. Should it be configurable per instruction, e.g. clock: ClockContext<CLOCK_10>?
-
Validation strictness: Should the dispatcher panic! if the clock account ID doesn't match the expected well-known ID (hard fail), or return an error? The current account constraint system panics on mismatch.
-
Option 1 position rule: Should ClockContext be required to come immediately after ProgramContext, or can it appear anywhere in the parameter list? ProgramContext is enforced at position 0 today.
-
Zero default vs. mandatory: In Option 2, what happens when a program is invoked without a clock account in pre-states (e.g. in unit tests)? Should timestamp default to 0, or should the dispatcher panic? Option 1 sidesteps this entirely.
-
Future: block_id vs. timestamp validity windows: Today, programs express time constraints in the output (BlockValidityWindow, TimestampValidityWindow). Would exposing time in the input context create confusion about when to use which pattern?
-
LEZ upstream: If LEZ eventually adds timestamp/block_id directly to ProgramInput (the cleaner long-term fix), the clock account workaround becomes redundant. Should we design the SPEL API so it can be transparently backed by either the clock account or a future ProgramInput extension?
Recommendation
Option 1 is unblocked today with no breaking changes and covers the primary use case. Option 2 is the cleaner long-term API but carries a migration cost.
A pragmatic path: implement Option 1 now, and if team consensus favours Option 2, migrate in a follow-up with a one-time ProgramContext::new() call update across the codebase.
Background
LEZ (Logos Execution Zone) does not expose the current timestamp or block number directly in
ProgramInput. Instead, LEZ maintains a dedicated Clock Program whose accounts are updated by the sequencer at every block. Each clock account stores:Three accounts exist, updated at different cadences:
CLOCK_01_PROGRAM_ACCOUNT_ID— every blockCLOCK_10_PROGRAM_ACCOUNT_ID— every 10 blocksCLOCK_50_PROGRAM_ACCOUNT_ID— every 50 blocksCurrently,
ProgramContext(the injected execution context available to instruction handlers) only exposes program identity:There is no way to access the current time or block height from within an instruction handler without manually declaring the clock account as an explicit
AccountWithMetadataparameter.Problem
Writing time-aware instructions (time locks, expiry checks, cooldowns, rate limits) is verbose and error-prone today. Developers must:
ClockAccountDatafrom raw bytesNo LEZ protocol changes are needed — both proposed options work by reading from the existing clock account inside
pre_states. The question is entirely about SPEL developer ergonomics.Option 1 — Explicit
ClockContextparameter (opt-in per instruction)A new
ClockContexttype that the macro recognises by name, similar toProgramContext. The developer opts in per instruction by declaring it as the second parameter. SPEL handles reading and decoding the clock account frompre_statesautomatically; it never appears in the IDL or ABI.Developer experience
The
ClockContextstruct:How it works at runtime
ClockContextparameter (newis_clock_context_type()check, like the existingis_context_type())pre_states[0]as the clock account, validates its account ID, decodesClockAccountDataClockContextand passes it as an argument — never appears in the instruction enum or IDLpre_states[1..]has_clock_context: boolis added toIdlInstructionso generated clients know to prepend the clock account when building transactionsComponent change map
src/context.rsClockContextstruct +new()src/lib.rsClockContextfrom preludesrc/idl.rshas_clock_context: booltoIdlInstruction(optional, skipped when false)src/lib.rsis_clock_context_type(), parse new parameter type, updateInstructionInfo, injectClockContextin match-arm codegen, readpre_states[0]as clock when present, emithas_clock_contextin IDL codegensrc/codegen.rsix.has_clock_context, prependCLOCK_01_PROGRAM_ACCOUNT_IDto the account list before building pre-statessrc/ffi_codegen.rsaccount_idsvec in the two-pass account resolversrc/logos_module_codegen.rstests/context_parameter.rsClockContextinjectionfixture_program/src/lib.rsNo breaking changes. All existing programs compile and behave identically.
Option 2 — Extend
ProgramContext(always available)ProgramContextgainstimestampandblock_idfields. The clock account is always prepended topre_statesby the dispatcher for every instruction. Every instruction gets time for free.Developer experience
The extended struct:
Component change map
src/context.rstimestampandblock_idfields; extendnew()signaturesrc/lib.rsProgramContext::new(...)call at line 836 to pass 4 args; read clock account frompre_states[0]unconditionally before dispatchingsrc/codegen.rsCLOCK_01_PROGRAM_ACCOUNT_IDto every instruction's account listsrc/ffi_codegen.rssrc/logos_module_codegen.rstests/context_parameter.rsProgramContext::new([1u32;8], [2u32;8])calls (×7 occurrences) to passtimestampandblock_idfixture_program/src/lib.rsProgramContext::new(program_id, [2u32;8])at line 836Breaking change: All direct calls to
ProgramContext::new()must be updated. Instruction handler function bodies are unaffected (the macro injects the context). The ABI and IDL are unchanged.Side-by-side comparison
ClockContextProgramContextClockContextdeclaredProgramContext::new()signaturehas_clock_contextflagClockContextexistsctx.timestampalways thereProgramContextOpen questions for team discussion
Which clock account to use?
CLOCK_01(most fresh, updated every block) seems right as the default. Should it be configurable per instruction, e.g.clock: ClockContext<CLOCK_10>?Validation strictness: Should the dispatcher
panic!if the clock account ID doesn't match the expected well-known ID (hard fail), or return an error? The current account constraint system panics on mismatch.Option 1 position rule: Should
ClockContextbe required to come immediately afterProgramContext, or can it appear anywhere in the parameter list?ProgramContextis enforced at position 0 today.Zero default vs. mandatory: In Option 2, what happens when a program is invoked without a clock account in pre-states (e.g. in unit tests)? Should
timestampdefault to0, or should the dispatcher panic? Option 1 sidesteps this entirely.Future: block_id vs. timestamp validity windows: Today, programs express time constraints in the output (
BlockValidityWindow,TimestampValidityWindow). Would exposing time in the input context create confusion about when to use which pattern?LEZ upstream: If LEZ eventually adds
timestamp/block_iddirectly toProgramInput(the cleaner long-term fix), the clock account workaround becomes redundant. Should we design the SPEL API so it can be transparently backed by either the clock account or a futureProgramInputextension?Recommendation
Option 1 is unblocked today with no breaking changes and covers the primary use case. Option 2 is the cleaner long-term API but carries a migration cost.
A pragmatic path: implement Option 1 now, and if team consensus favours Option 2, migrate in a follow-up with a one-time
ProgramContext::new()call update across the codebase.