Skip to content

feat: expose block timestamp and block ID in instruction execution context #226

@vpavlin

Description

@vpavlin

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:

  1. Manually declare a clock account parameter
  2. Manually decode ClockAccountData from raw bytes
  3. 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

  1. Macro detects ClockContext parameter (new is_clock_context_type() check, like the existing is_context_type())
  2. Dispatcher reads pre_states[0] as the clock account, validates its account ID, decodes ClockAccountData
  3. Constructs ClockContext and passes it as an argument — never appears in the instruction enum or IDL
  4. Regular accounts continue at pre_states[1..]
  5. 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

  1. 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>?

  2. 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.

  3. 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.

  4. 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.

  5. 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?

  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions