slot_deadline

152cu · happy

Abort if the current slot is past the deadline you signed for.

program idSLDyTxMbunLA51WADZKpXNZ49mFnhsPxtZSp4Rbr4ja

What it does

slot_deadline answers one question: is the current slot still at or before the deadline you signed for? If yes, continue. If no, abort the whole transaction atomically.

Useful for off-chain-signed intents that go stale. The keeper that lands the tx is not the entity that signed it; if it lands late, slot_deadline catches it. Pair with signer_allowlist to bound who can submit and how long the intent stays valid.

Zero accounts, one syscall, 152 CU on the happy path. The syscall (sol_get_clock_sysvar) is what costs the bulk of it; the comparison itself is one instruction.

How to use it

import { slotDeadlineIx } from "@solana-asm/shield"import { PublicKey, Transaction } from "@solana/web3.js" const SLOT_DEADLINE = new PublicKey("SLDyTxMbunLA51WADZKpXNZ49mFnhsPxtZSp4Rbr4ja") const slot = await connection.getSlot() const tx = new Transaction()tx.add(slotDeadlineIx({  programId: SLOT_DEADLINE,  maxSlot: BigInt(slot + 100),}))tx.add(yourDestinationInstruction)

Assembly walkthrough

lines 1-4

Constants for the byte offsets we will read

.equ INSTRUCTION_DATA_LEN, 0x0008.equ INSTRUCTION_DATA,     0x0010.equ CLOCK_BUF_SIZE,       40.equ CLOCK_SLOT_OFF,       0

Every guard starts the same way: name the byte offsets we are about to read, then load them by name. No struct definitions, no parser library, just numbers.

Why 8 and 16? The runtime hands every program one big block of bytes (r1 points at the start of it). For a program that declares ZERO accounts, that block starts with two u64 header fields: account count first (always 0 here), then instruction data length, then the instruction data itself. Each u64 is 8 bytes. So the length sits at byte 8 (0x0008) and the data starts at byte 16 (0x0010). That is the entire reason for those two numbers.

Other Shield guards have very different-looking offsets (0x2868, 0x2870, 0x2910). Same fields, just shoved further along because declaring accounts inserts a chunk of per-account info between the header and your ix data. slot_deadline declares no accounts, so nothing gets inserted, and the offsets stay tiny.

Why 40 for CLOCK_BUF_SIZE? The Clock sysvar (Solana's runtime struct of timing data) serializes to exactly 40 bytes: slot (u64) + epoch_start_timestamp (i64) + epoch (u64) + leader_schedule_epoch (u64) + unix_timestamp (i64). Five fields of 8 bytes each = 40. We need a 40-byte buffer to receive a copy of it.

Why CLOCK_SLOT_OFF = 0? slot is the first field of the Clock struct above, so it sits at byte 0 of whatever buffer the syscall fills.

lines 8-9

Check that the caller gave us exactly 8 bytes of ix data

  ldxdw r2, [r1 + INSTRUCTION_DATA_LEN]  jne r2, 8, bad_ix_data

Read the u64 ix data length the runtime wrote at INSTRUCTION_DATA_LEN into r2, then branch to bad_ix_data unless it equals 8.

Why exactly 8? The caller is supposed to hand us one u64 max_slot, no more, no less. A 7-byte input would leave the top byte of the u64 as whatever was in memory before. A 9-byte input could sneak past a permissive SDK and let an attacker stuff extra data into the tx. Strict equality blocks both.

line 11

Save the caller's deadline before we make a syscall

  ldxdw r6, [r1 + INSTRUCTION_DATA]

Load the caller's max_slot from the ix data into r6.

Why r6 specifically? The next thing we do is call sol_get_clock_sysvar. The Solana syscall ABI lets the runtime overwrite r0 through r5 (the return register and the five argument registers). Anything you leave in r0-r5 across a syscall is gone. r6 through r9 are guaranteed to survive. Putting max_slot in r6 means we still have it after the syscall returns.

lines 13-15

Reserve a 40-byte buffer on the stack and ask the runtime for the Clock

  mov64 r1, r10  sub64 r1, CLOCK_BUF_SIZE  call sol_get_clock_sysvar

r10 is the stack frame pointer. Subtracting 40 from it gives a pointer to a 40-byte slot at the top of our stack frame. (sBPF stacks grow downward, same direction as x86 and ARM.) We do this twice: once to hand the pointer to the syscall in r1, again later to read the result.

call sol_get_clock_sysvar invokes the runtime's clock syscall. The convention is: pass the destination pointer in r1, runtime writes 40 bytes of live Clock data into that buffer, then returns.

This is the only syscall in slot_deadline and it is the reason the guard costs ~152 CU on the happy path. The arithmetic and compares around it are 1-2 CU each. The syscall is the rest. Reading sysvars is expensive: the runtime has to look up sysvar state, copy it, and validate the call.

lines 17-19

Read the current slot out of the buffer

  mov64 r2, r10  sub64 r2, CLOCK_BUF_SIZE  ldxdw r3, [r2 + CLOCK_SLOT_OFF]

Rebuild the buffer pointer in r2. We have to do this because r1 may have been overwritten by the syscall (see the ABI note above). r10 - 40 is a fixed expression, not data the syscall touched, so it always points to the same slot we just filled.

ldxdw r3, [r2 + 0] reads the first 8 bytes of the buffer. Since CLOCK_SLOT_OFF is 0, that is the slot field. r3 now holds the current slot number, freshly written by the runtime moments ago.

line 21

The actual safety check, one instruction

  jgt r3, r6, deadline_missed

jgt r3, r6, deadline_missed is unsigned greater-than: if the current slot is STRICTLY greater than the caller's max_slot, jump to the failure exit.

Equal passes. If you signed for 'must execute at or before slot N' and the current slot is exactly N, the tx is still valid. Strict greater-than makes the boundary inclusive.

Every line of code before this was loading values into registers. Every line after is exit plumbing. This single jgt is the entire check.

lines 23-24

Happy path exit

  mov64 r0, 0  exit

Set r0 to 0 (success), then exit. The runtime reads r0 to decide what happened: 0 means 'this instruction passed, continue with the next ix in the transaction', anything non-zero means 'abort the whole transaction atomically and revert every state change made so far'.

lines 26-42

Failure exits and the string table

deadline_missed:  lddw r1, msg_late  mov64 r2, 15  call sol_log_  mov64 r0, 1  exit bad_ix_data:  lddw r1, msg_bad  mov64 r2, 11  call sol_log_  mov64 r0, 2  exit .rodata  msg_late: .ascii "deadline missed"  msg_bad:  .ascii "bad ix data"

Two failure paths, same shape: put the log string's address in r1, its byte length in r2, call sol_log_, set the exit code in r0, exit.

deadline_missed logs 'deadline missed' (15 bytes) and exits 1 (condition failed). Every Solana RPC and indexer captures program logs, so the SDK can read this string from the failed tx and tell the user 'your transaction missed the deadline' instead of a generic 'transaction failed'.

bad_ix_data logs 'bad ix data' (11 bytes) and exits 2 (malformed ix data). The caller did not give us exactly 8 bytes.

There is no exit 3 here. Exit 3 means 'invalid account' across all Shield guards, but slot_deadline declares zero accounts, so there is nothing for the caller to get wrong on the account side.

The .rodata section at the bottom holds the literal bytes of the two log strings. They get baked into the program's compiled .so at link time. lddw at runtime loads their ADDRESSES; the bytes themselves are part of the program image and never get copied.

Exit codes

Exit codes for this guard
codenamelog line
0Success(no log)
1ConditionFaileddeadline missed
2BadInstructionDatabad ix data