Skip to main content
The Scheduler system contract at 0x00000000000000000000000000000000000000fe runs calls at a future slot (a block timestamp in seconds), a block height, or after an oracle condition is met. The account that calls schedule, named payer in the API, deposits the gas token into escrow to fund execution. Every block, the proposer derives the due entries from the scheduler’s storage and prepends one system transaction per entry. Every node re-derives the same set and rejects a block whose leading transactions differ. Each run produces a transaction receipt and logs. These system transactions have sender 0xFE and type byte 0x7e. Only block execution creates them. Entries support these options:
  • Open entries carry their calls in plaintext.
  • Sealed entries keep their calls encrypted until the corresponding beacon key becomes available.
  • Conditional entries become eligible after an oracle write satisfies their condition. Their calls can be open or sealed.

Policy

The network sets the policy when it installs the scheduler. Each field has a public getter: Once the scheduler activates, a block’s timestamp may be at most 2 seconds ahead of a validator’s clock and 60 seconds behind it.

Scheduling an entry

  1. approve(0xFE, amount) on the scheduler’s gas token, then deposit(amount).
  2. If the entry uses a token reserve, approve(0xFE, reserveAmount) on reserveToken before scheduling it.
  3. Call schedule(Params). It reserves cost * gasPrice from the available escrow balance, places any token reserve on hold, and returns the entry ID.
  • calls: one to eight (target, gasLimit, data) calls, run in order and atomically. If any call fails, every earlier call reverts. No target may be the zero address, the scheduler, the gas token, or a MIP20 token. The ABI-encoded calls may take at most 4096 bytes.
  • slot (strictly after the current block’s timestamp) or height (strictly after the current block number): exactly one of them, within the horizon. A height trigger becomes due at that height and runs when its dependencies and the block’s capacity permit. Height triggers have no lateness deadline and cannot recur.
  • gasLimit: the gas limit for executing the calls. It covers each call’s own limit, the overhead of making each call, and capturing any reserve, including receive policy screening and possible quarantine writes.
  • gasPrice: at least minGasPrice.
  • class: 0 Guaranteed or 1 BestEffort.
  • lateExecute: past slot + maxLateSlots, run anyway (catch up after a halt) instead of expiring.
  • maxRetries, backoffSlots: retries after a failed run.
  • periodSlots, remaining, pauseOnFail: a recurrence.
  • reserveToken, reserveAmount: an optional reserve. Use both zero for none.
  • dependsOn: up to eight earlier entries, each (id, finished). The dependency must have succeeded, or with finished merely ended (a paused series counts as ended). A recurring entry cannot have dependencies.

Cost

An entry is budgeted at cost(gasLimit, encodedCalls.length, dependencies, recurring, reserved): cost must fit gasPerBlock. The amount reserved for execution is cost * gasPrice. A run pays this amount in full to the block’s proposer, regardless of the gas the target calls use.

Capacity

  • schedule rejects a slot or height that already holds maxEntriesPerBlock entries.
  • A Guaranteed entry reserves its cost from the slot’s or height’s guaranteedGasPerSlot budget. schedule rejects the entry if the remaining budget is too small. Each period of a Guaranteed series tries to reserve again. A recurrence whose next slot lacks Guaranteed capacity runs that period as BestEffort.
  • A retry, a recurrence, or a moved entry spills to the first of the next 8 slots with room. When none has room, the retry fails or the recurrence starves.
  • A Scheduled entry holds its place in the queue and its Guaranteed capacity until it runs, is canceled, or expires. Holding that place locks cost * gasPrice of escrow at the entry’s own gas price, which is at least minGasPrice.

Reserves

A reserve holds the payment amount until the entry executes. reserveToken must be a MIP20 token other than the scheduler’s gas token. This keeps the hold from locking the gas-token balance needed to pay fees for cancel or withdraw.
  • schedule holds reserveAmount out of the payer’s spendable balance (MIP20.holdFrom).
  • The scheduler captures the hold into the first call’s target, then executes the calls atomically. A plain payment is one call with target set to the recipient and empty data.
  • A failed run reverts the capture, so the hold stays through retries. A final failure, cancel, or expire releases it in full.
  • A recurrence holds the next period’s amount when it re-enqueues, so approve enough for every period. If the hold fails, the recurrence starves and its reserved execution fee returns to the available escrow balance.
Capture forces a refused credit into ReceivePolicy quarantine, even when the target selected REVERT. If the subsequent calls succeed, the entry becomes Executed and consumes its reserve while the intended recipient remains unpaid. Verify the reserve token’s exact Transfer from the payer to the target before recording payment. For quarantine, save the matching quarantine receipt and transfer from the payer to ReceivePolicy. Record the reserve as consumed and resolve recovery before authorizing another payment. A later target revert rolls back both the capture and its quarantine receipt.

When an entry runs

An entry becomes due when the block’s timestamp reaches slot or its number reaches height. Execution also depends on available capacity, completed dependencies and, for a sealed entry, its decryption key. A block’s budget goes to due Guaranteed entries before BestEffort ones, and to BestEffort entries in descending gasPrice order. All selected entries must fit the gas budget. Guaranteed entries count toward maxEntriesPerBlock when selecting BestEffort entries, but that count does not limit Guaranteed entries. An entry waits when it exceeds the remaining budget or has a dependency still waiting to execute. A waiting BestEffort entry or an entry delayed by a dependency moves to the first of the next 8 slots or heights with room, keeping its deadline. This lets the queue cursor advance past its original position. A Guaranteed entry delayed by a dependency becomes BestEffort when it moves, as a retry does. These entries keep their position:
  • a sealed entry, which waits for its decryption key.
  • a Guaranteed entry waiting for budget.
  • an entry that finds no room.
Entries move in id order, so a dependent never lands before its dependency in the same queue. One that comes due before its dependency is held back again. The chosen entries run height entries before slot entries, then by key, then Guaranteed before BestEffort, then by id, except that an entry follows any dependency chosen for the same block. The Guaranteed class reserves capacity for its slot or height. After a gap in block production, skipped slots’ Guaranteed entries also compete for the next block’s gas budget and run oldest first. Each target sees msg.sender == 0xFE and must check the authorization supplied in the calldata. The scheduler marks an entry Executed before making its calls, which prevents a target from canceling, expiring or rerunning that entry during execution.

Failures, retries, and recurrence

A reverting call fails its run. An unfinished dependency delays the entry until the dependency finishes. A failed entry can retry up to maxRetries times as a time-triggered BestEffort entry. Each retry reserves another execution fee from the available escrow balance and targets now + backoffSlots, or the next slot when backoffSlots is zero. A dependency that was required to succeed but failed, was canceled, expired or paused causes the entry to fail immediately. A recurrence schedules another period after the previous one succeeds or ends in failure. Its timing is calculated from the previous due slot. The series stops when remaining reaches zero. It emits RecurrenceStarved when it cannot fund the execution fee or token reserve for another period, or find room in the 8 slots starting at its due slot. With pauseOnFail, a failed period pauses the series (Paused) until the payer calls resume(id) or cancel(id). Resuming requires enough funds for the execution fee and token reserve, plus capacity in one of those 8 slots. If these requirements are unmet, resume reverts and the series stays paused.

Cancelling, expiring, and withdrawing

Before execution, the payer can call cancel(id) to recover cancelRefundBps of the reserved execution fee. Anyone can call expire(id) for a full refund when an entry meets one of these conditions:
  • It has passed its deadline with lateExecute disabled.
  • A queue cursor (cursorSlot or cursorHeight) has passed its key while the entry remains Scheduled.
  • It is a conditional entry whose item has left the oracle catalog.
A block advances the cursors before executing its selected entries. If an entry remains Scheduled after its system transaction reverts, expire recovers its reserved amount, as described in What a run looks like. Calling expire during a scheduled entry’s execution reverts with Running. Cancellation and expiry release the token reserve in full and free the entry’s queue position and Guaranteed capacity. Expiry also removes conditional entries from the watch list. To recover available escrow funds, the payer calls withdraw(amount). For a withdrawal, verify a finalized Transfer(scheduler, payer, amount) from the gas token before recording that the payer received the tokens. If its receive policy refuses the credit in REVERT mode, withdrawal reverts and preserves the available escrow balance. In QUARANTINE mode, withdrawal succeeds and emits Withdrawn, while the funds go to ReceivePolicy. Save the full quarantine receipt and follow the recovery procedure.

Conditional entries

A conditional entry waits for an oracle write that satisfies its condition. Set when = (item, kind, operand) and set slot to the earliest timestamp at which a write may trigger it. item is the catalog item’s ID, keccak256(name). The item must be listed in the oracle catalog and have at least one recorded write, or scheduling reverts with BadCondition.
  • The entry watches its item (watchersOf(item), at most 256 per item) under the key WATCH_KEY (bit 62). It is never due and never expires while it watches, unless its item leaves the catalog.
  • Each block evaluates the watchers of every item written since they were last evaluated, normally the items the parent block wrote. watchedHeight(item) is the height of the write they last saw.
  • An entry fires when its predicate holds on the item’s latest state and the write’s timestamp reaches the entry’s not-before slot. It moves into the first of the next 8 slots with room and follows the rules for time-triggered entries, including deadlines, retries and dependencies. If all 8 slots are full, it keeps watching until the item’s next write, including a heartbeat.
  • If the value already satisfies the predicate when the entry is scheduled, the next qualifying write triggers it.
  • A halt or resume through a control item changes the item’s flags. WHEN_NOT_HALTED fires at the first write after a resume.
  • Conditional entries are BestEffort only, with no height trigger or recurrence.
  • An entry leaves the watch list when it fires, is canceled, or expires.
  • Each block evaluates at most 1024 watchers, apart from the budget for due entries, starting from an item that rotates with the block number. It reads only an entry’s predicate (a few words, never its calls). An item past that budget is evaluated in a later block on its latest write then, so a value that moved back across the threshold in between does not fire it. Filling an item’s 256 places, or enough items’ places to spend the budget, takes that many entries locked at minGasPrice.

Call authority

A call carries 0xFE’s authority over its escrow and the holds and allowances granted by accounts that call schedule. The scheduler therefore rejects MIP20 tokens as call targets, including targets that become MIP20 tokens after scheduling. To relay an ERC-3009 authorization, target a relayer contract that submits it.

What a run looks like

The system transaction’s hash is:
The attempt counter gives each retry a distinct hash. gasLimit is the entry’s budgeted cost, so the reserved amount is the transaction’s gasLimit * gasPrice. RPC presents the transaction using EIP-1559 fields, with 0xFE as sender, the attempt as nonce and a zero signature. A single-call entry shows the target address and its data. A batch shows 0xFE as the target and the ABI-encoded calls as data. During execution, the system calls execute(id) on the scheduler, which makes the target calls and records their outcome. The transaction receipt’s status describes execution of execute(id). A 0x7e receipt has one of three outcomes:
  • Success. execute(id) ran and paid the reserved amount to the proposer. Read its logs from 0xFE to determine whether the target calls succeeded: Executed(id, attempt) records success, while ExecutionFailed(id, attempt, willRetry) records a failed call or dependency. Read status(id) for the entry’s resulting state.
  • Reverted, with gas used. execute(id) reverted, so the execution fee remains in escrow. The entry stays Scheduled behind the queue cursors and is excluded from future execution. Dependencies treat it as expired, and expire(id) returns its whole reserved amount.
  • Reverted, with zero gas used. The system skipped execute(id) and charged no fee. Either an earlier target call canceled the entry, applying its cancellation refund, or escrow could not cover the execution fee. In the latter case, the entry stays Scheduled and can be expired.
Tracing (debug_trace*) replays a scheduled transaction as the same system call block execution made, from 0xFE to itself. Pending-block RPC ("pending") does not include due entries.

Halts and catch-up

  • Entries past their deadline run only with lateExecute. The rest wait for expire.
  • Catch-up after a halt scans at most 4096 slots (and 4096 heights) and reads at most 1024 queued entries per block, heights first, then slots, each oldest first. Each read costs a few words of the entry’s fixed fields. The calls are read, and decrypted when sealed, only for entries selected for execution.
  • The cursors (cursorSlot, cursorHeight) mark the first key still holding a waiting or unread entry. Each block clears the queues and Guaranteed reserves of the keys the cursors pass. When the scheduler activates on a running chain, the cursors start at the activation block’s next slot and height.
  • Scheduled runs count against the block gas limit like any transaction.

Interface

A finished entry (Executed, Failed, Cancelled, Expired) is pruned to its first word: status(id) and the first fields of entry(id) stay readable, and dependents read its status. status is 0 None, 1 Scheduled, 2 Executed, 3 Failed, 4 Cancelled, 5 Expired, 6 Paused. Transactions: deposit, withdraw, schedule, cancel, expire, resume. Reads: status(id), entry(id), queueAt(key), reservedGas(key), balance(payer), cost(...), nextId(), cursorSlot(), cursorHeight(), watchersOf(item), watchedHeight(item), sealedPending(slot), and the policy getters. A height’s queue key is the height with bit 63 (HEIGHT_KEY) set. Events: Scheduled, Executed, ExecutionFailed, Recurred, RecurrenceStarved, Paused, Cancelled, Expired, Deposited, Withdrawn. Errors: NotChain, NotPayer, NotScheduled, NotPaused, NotDue, NotExpired, BadTarget, BadCalls, BadTrigger, BadDependency, SlotNotInFuture, BeyondHorizon, GasLimitOutOfRange, ZeroGasPrice, GasPriceTooLow, DataTooLarge, BadClass, SlotReserveFull, SlotFull, InsufficientEscrow, TransferFailed, BadReserve, ReserveIsGasToken, CallFailed, CannotRecur, BadSeal, BadCondition, WatchFull, Running.

Encrypted payloads

Seal the calls as follows, with group_key = Beacon.groupKey() at 0xFF and payer the account that will call schedule:
  1. calls is abi.encode(Call[]), as the Scheduler stores an open entry’s calls, at most 4092 bytes.
  2. Pad it to a size class: body = u32_be(len(calls)) || calls, then zeros up to the next power of two, at least 256 and at most 4096 bytes.
  3. Draw a random 32-byte salt. The commitment is keccak256(salt || body).
  4. Draw a random 32-byte data key and timelock-encrypt it to the identity u64_be(slot): 112 bytes.
  5. Draw a random 12-byte nonce and encrypt salt || body under the data key with ChaCha20-Poly1305 (RFC 8439), with associated data u64_be(slot) || payer (8 + 20 bytes). Append the 16-byte tag to the encrypted bytes.
The ciphertext is:
The ciphertext is the body plus 172 bytes and reveals its size class. Pass it to schedule in ciphertext and the commitment in commitment, with calls empty. The salt keeps the commitment from confirming a guess at the calls. schedule accepts a ciphertext longer than 172 and at most 4268 bytes. An entry that does not open to a well-formed body fails when the entry runs. Every entry at a slot opens with the same key. The associated data also binds the payload to payer, so successful decryption requires the scheduling account’s address to match the address used during encryption.

Rules

  • A sealed entry requires a slot trigger and an installed beacon group key. It supports at most 4092 bytes of calls, with recurrence and token reserves disabled. A sealed conditional entry uses the slot as its not-before time.
  • Everything else (gas, class, retries, dependencies) works as for an open entry. The reserved amount is priced on the ciphertext’s length, padding included.
  • A sealed entry keeps its queue position while waiting for its slot or condition key.

What stays hidden

The payer, slot, gas limit and price, class, dependencies and size class are public before execution. Targets, data and the calls’ exact length stay hidden until the decryption key becomes available.
  • A sealed entry pays through its calls, for example by submitting a signed ERC-3009 transfer through a relayer contract. The spending account must keep enough funds available until execution.
  • A gas limit rounded up, to a power of two for example, hides how much work the calls do.

Opening

sealedPending(slot) counts a slot’s unrevealed entries. Proposers prioritize beacon keys for slots with pending entries for 30 seconds after each slot ends (GRACE_SLOTS). Block production can continue while a key remains unavailable, and omitting a key does not make a block invalid. The block that carries the slot’s key applies its beacon update first. It decrypts the calls, checks the commitment and stores the revealed calls before executing the entry as a system transaction. If the entry exceeds that block’s remaining budget, it runs in a later block. Anyone can decrypt its calls once the key is onchain. An entry fails without retry if decryption, commitment verification or decoding fails, or if it exceeds eight calls or names a forbidden target. A payload encrypted under a group key the beacon no longer holds is revealed as empty and fails, consuming its reserved amount. A queued entry still sealed at block.timestamp > entry.key + 1 + 30 has missed its decryption deadline. expire(id) refunds its reserved amount in full, including when lateExecute is set. Here entry.key is the queued slot, which ends at entry.key + 1, followed by a 30-second grace period. Conditional entries enter this grace period once they leave the watch list for a slot.
A validator recovers the key from its peers’ partials shortly before a block carries it. A proposer that leaves an available key out of its block can read the calls and order its own transactions ahead of them in that block. The next honest proposer carries the key.

Sealed conditional entries

A conditional entry can also be sealed: its calls stay hidden until the condition holds. Seal them to the condition instead of the slot:
payer is the account that calls schedule, and slot is the entry’s not-before slot. Seal as for a slot, with two changes: the data key is encrypted to the identity condition (32 bytes), and the associated data is condition. Pass the ciphertext and commitment as for a sealed entry, together with when.
  • The validators’ signers release a partial signature over condition only for a finalized block whose state, proven against the block’s state root, holds an oracle write of item that satisfies the predicate and is no older than the not-before slot: the same rule that fires the entry. Nodes ask for it only once a block has fired the entry, proving the state its watchers were evaluated on, so an entry left watching (no room, or past the watcher budget) keeps its calls hidden.
  • The required number of validator partial signatures reconstructs the condition’s key. A proposer carries it in the block’s beacon update, which accepts at most 32 condition keys per block. After inclusion, Beacon.conditionKey(condition) returns the key.
  • The entry fires as an open conditional entry does, then waits for the condition’s key while holding its slot’s cursor. Once the key is included, the entry can open and run subject to the block’s capacity. It expires with a full refund if it is still sealed 30 seconds after the end of the slot it fired into.
  • A payer’s entries that share a condition share its key. Once one fires and its key lands, every payload that payer sealed to that condition opens, including one still watching for room. Use different not-before slots when entries need independent disclosure times. The condition includes payer, so each scheduling account uses a distinct key. Once that key is public, anyone can decrypt the calls.
  • sealedPending counts slot-sealed entries only. Sealed conditional entries use their condition keys and have no key-inclusion requirement for block validity.

Timelock key encoding

Encryption to a slot is Boneh-Franklin timelock encryption on BLS12-381 with the Fujisaki-Okamoto transform, public keys in G1 and signatures in G2. It encrypts a 32-byte message M to an identity id under the group key PK (groupKey()). A slot’s identity is u64_be(slot). A sealed conditional entry’s is its 32-byte condition.
  1. Q = hash_to_G2(varint(len(ns)) || ns || id) with the RFC 9380 DST BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_, where ns = "metal-beacon-v1" || uint256_be(chain_id) || genesis_hash and varint is unsigned LEB128. keyAt(slot) is the group’s BLS signature on Q.
  2. Draw a random 32-byte sigma.
  3. r is the 48 bytes of expand_message_xmd(SHA-256, sigma || M, "TLE_BLS12381_XMD:SHA-256_SSWU_RO_H3_"), read big-endian and reduced modulo the group order.
  4. U = r * G1 (48-byte compressed).
  5. g = e(r * PK, Q), serialized as 576 big-endian bytes in blst’s blst_bendian_from_fp12 layout.
  6. V = sigma XOR sha256("h2" || g) and W = M XOR sha256("h4" || sigma).
The ciphertext is U || V || W, 112 bytes. With the key K, decryption computes g = e(U, K), recovers sigma from V and M from W, and accepts only when U equals r * G1 recomputed from them. Decryption requires the corresponding slot or condition key.

Read entry state

Use this interface to diagnose a pending entry at a chosen block. A finished entry is pruned, so save its parameters and relevant state before completion.
key === (1n << 62n) means the entry is still waiting for an oracle condition. A height queue key has bit 63 set. Other keys are queued timestamps. For a fired condition, key is the slot into which it fired and deadlineSlot is that slot plus the configured late window. dueSlot remains its original not-before timestamp. Use key for an unrevealed entry’s 30-second grace period, after excluding the watch-list sentinel. An entry remains sealed when (flags & 16) != 0. Simulation of expire(id) checks complete eligibility, including item removal and cursor position.