Status: Draft
Type: Standards Track
Category: Core
Created: 2026-01-19
Requires: CIP-1, CIP-3
1. Abstract
This proposal defines Cowboy’s native Timer mechanism — a height-triggered, one-shot scheduling primitive with an explicit fee payer and self-terminating lifecycle. Actors register timers for a future block height; each timer records who pays (fee_payer), how much gas it may consume per fire (gas_limit_per_fire), and when it gives up (expires_at). At the End of Block (EOB), the protocol collects all timers whose height matches the current block, pre-charges the fee payer, executes the handler, refunds unused gas, and removes the timer. A timer that its fee payer can no longer fund, or that has passed its TTL, is destroyed on the next block without executing. Execution follows insertion order (FIFO within a height bucket) — no priority queue or bidding is involved (see §9 / CIP-1 v3 Part III for the EIP-1559 target design).
Metering follows CIP-3’s dual-metered model (Cycles/Cells). Protocol-level timer parameters (max_ttl_blocks, max_cycles_per_fire, max_cells_per_fire, max_timers_per_actor, gc_cycles_per_block) are held in a governed TimerConfig, updated through the same SubmitProposal → CastVote → ExecuteProposal path as BasefeeConfig.
2. Motivation
On-chain actors need the ability to schedule future execution without relying on external keepers. Use cases include:- Periodic tasks: An actor schedules a timer in its handler to re-fire at a future height, creating a heartbeat loop.
- Deferred settlement: After an off-chain computation (CIP-2), an actor schedules a follow-up action at a known future height.
- Time-locked operations: Vesting, escrow release, or governance proposal execution at a predetermined height.
3. Specification
3.1 Timer Data Structure
Timer codec version is 5 (node/storage/src/types.rs). The decoder accepts legacy versions 3–5 on read, defaulting fields introduced in later versions to 0 (skip_count / auction-fee fields added in v4, retry_count in v5) — a forward-compatible field addition does not require a chain wipe or migration pass.
3.2 Timer Index
Timers are stored in two structures:- Timer store:
keccak256(timer_id) → Timer— the canonical timer record. - Height index:
keccak256(height.to_be_bytes()) → TimerList— a list oftimer_idvalues for each height, maintained in insertion order.
state_root.
3.3 Timer ID Generation
Timer IDs are deterministically derived:nonce is the transaction nonce of the calling actor at the time of scheduling. This guarantees uniqueness across all timers.
4. API
4.1 Python Host API
Actors interact with timers via four host calls exposed to the PVM:4.2 Constraints
- Future height only:
heightMUST be strictly greater than the current block height. Scheduling at the current or past height returns an error. - One-shot semantics: Each timer fires exactly once and is immediately removed. To create recurring behavior, the actor must schedule a new timer from within its handler.
- Custom handler: By convention, if
payloadis valid JSON containing{"_handler": "<name>", "_payload": <json-value>}, the timer invokes the named handler with the inner payload re-serialized as raw JSON bytes (the_payloadvalue is not base64-decoded). If_payloadis omitted, the outer object minus_handleris passed as the payload. Otherwise, the default handlerhandle_timeris invoked. - Payload size: Maximum 1,048,576 bytes (1 MiB).
- Handler name: Maximum 256 bytes.
fee_payervalidation (enforced at schedule time):ZERO(burn address) is rejected.- The reserved system-actor band
0x01..=0xFF(every address belowRESERVED_SYSTEM_ADDRESS_LIMIT = 0x100) is rejected as a third-partyfee_payer— covers every currently assigned system actor (runner registry / dispatcher / verifier / secrets / TEE / token registry / entitlement registry / RAS / basefee / governance / event-subscription0x1D) plus the reserved slots up to0xFF. An actor cannot delegate payment to a system actor.- Self-funding exemption. A system actor MAY name itself as
fee_payer(i.e.fee_payer == actor_addresswithin the system band) — this is the actor-pays-itself case, not third-party delegation. Required for the Storage Manager (0x0A) to fund its own PoR-challenge timer (CIP-9 §5.6; worked example in §6.3). The exemption is strictlyfee_payer == actor_address: one system actor still cannot name a different system actor, and thetx_senderbranch below is never opened for a system-bandfee_payer(that would let a crafted sender bill a system actor).
- Self-funding exemption. A system actor MAY name itself as
fee_payerMUST be either the executingactor_address(actor pays itself) or the currenttx_sender(deployer / invoker pays). Any other third-party address is rejected withInvalidInput.- Rationale. Without this check, a hostile actor could schedule
timers with an arbitrary victim’s address as
fee_payer; the block-level pre-charge (§6.3 step 4) would then drain the victim’s account on every fire until depleted — a straight Broken-Access-Control class bug. The self-or-sender rule ensures every debit has implicit consent: either the actor is paying for its own execution, or the tx signer chose to fund this timer by submitting the scheduling transaction. - Future opt-in third-party sponsorship (NOT in this revision) would
require an explicit on-chain allowance (e.g.
fee_payerpre-authorizes the scheduling actor via a storage-based quota) or a signed authorization carried in the scheduling tx. Deliberately deferred — the current rule covers the two “typical” cases Model B.2 enumerates (actor self-funds; deployer funds its actor) with no attack surface.
- Rationale. Without this check, a hostile actor could schedule
timers with an arbitrary victim’s address as
- Default when omitted:
actor_address.
gas_limit_per_fire: MUST be ≤TimerConfig.max_cycles_per_fire.expires_at: MUST satisfyexpires_at ≤ current_height + TimerConfig.max_ttl_blocks. A timer whoseexpires_athas passed is destroyed on its next scheduled fire without executing (see §5.4).- Per-actor cap: An actor may hold at most
TimerConfig.max_timers_per_actorlive timers at any time (maintained via a per-actor secondary index). cancel_timerauthorization: The PVM host call is only permitted to cancel timers wheretimer.actor_address == executing_actor. Cross-actor cancellation viacancel_timeris rejected asUnauthorized(closes CVE-class bug present prior to this revision). System-level cancellation paths (owner, governance) are exposed via system instructions, not host calls — see §5.4.
4.3 Side Effect Semantics
Timer scheduling and cancellation are side effects of transaction execution:- Scheduled timers pass the §4.2 validation (fee_payer, TTL, gas caps, per-actor cap) during the originating tx’s execution; validation failure reverts the tx. Accepted timers are collected in
ExecutionSideEffects.scheduled_timersand persisted to storage after the transaction commits. - Cancelled timers pass the §4.2 authorization check (or the §5.4 system-instruction authorization) and are collected in
ExecutionSideEffects.cancelled_timers, then removed from storage after the transaction commits. Any pre-chargedmax_costis refunded on the same commit. - On transaction rollback, all timer side effects are discarded — no scheduled timer is persisted, no cancelled timer is removed, no pre-charge is finalized.
5. End-of-Block Delivery
5.1 Execution Order
Timer delivery occurs at the end of block, after all user transactions have been executed. The sequence withinprocess_block() is:
- Execute all user transactions (TX phase).
- Query
get_timers_by_height(current_height)— returns all due timers in insertion order (FIFO). - Classify each due timer per §5.4:
- TTL expired (
current_height > expires_at) or insufficient funds → remove underTIMER_GC_CYCLES(§6.5), emit the corresponding event, skip execution. - Within TTL and funded → pre-charge
fee_payer(§6.3 step 4), construct a deferred transaction targeting the actor’s handler, enqueue.
- TTL expired (
- Remove the timer from both the timer store and the height index after each classification concludes.
- Execute the enqueued deferred transactions; refund any over-reserved gas to
fee_payer(§6.3 step 6).
5.2 Deferred Transaction Construction
Each fired timer produces a deferred transaction with the following properties:
The zero-hash origin indicates “this tx has no parent user tx” — it does not imply free execution (see §6.3). Unlike user-initiated deferred transactions, timer deferred txs do not consume from a parent tx’s
deferred_gas_pools entry; their gas budget is drawn from fee_payer’s account directly.
5.3 Same-Block Prohibition
Timers created within the current block’s transactions MUST NOT fire in the same block. This is enforced by theheight > current_block_height constraint in schedule_timer.
5.4 Timer Lifecycle — Three Exit Paths
A timer is guaranteed to disappear through exactly one of the following paths:- Natural fire (§5.2, §6.3):
heightarrives,fee_payerhas sufficient funds, handler runs (success or revert); timer is removed. - Insufficient-funds self-destruct: at fire time,
balance(fee_payer) < max_cost(see §6.3). The timer is removed without executing. Event:TimerCancelledInsufficientFunds { timer_id, fee_payer, required, available }. Clearing runs under the GC budget (§6.5), not the execution lane. - TTL expiry: at fire time,
current_height > expires_at. The timer is removed without executing. Event:TimerExpired { timer_id, expires_at, current_height }. Also under the GC budget. - Explicit cancellation, via either:
- Actor-self —
pvm_host.cancel_timer(timer_id)PVM syscall. The host-call layer requirestimer.actor_address == executing_actor; cross-actor cancellation at this path is rejected asUnauthorized. - Validator-set emergency —
SystemInstruction::CancelTimer { timer_id }(opcodeSYS_CANCEL_TIMER = 48). Authorization:sender ∈ system_deployers(same gate asUpgradeActorandUpdateBasefeeConfig). Idempotent: cancelling a timer that has already self-destructed (TTL / insufficient funds, §5.4 paths 2–3) succeeds as a no-op. Emitstimer.cancelled_by_governancewith thetimer_idas event data.
- Actor-self —
- Actor-self —
pvm_host.extend_timer(timer_id, new_expires_at)PVM syscall. Caller must own the timer; new TTL must be strictly greater than the current block height. - Validator-set emergency —
SystemInstruction::ExtendTimer { timer_id, new_expires_at }(opcodeSYS_EXTEND_TIMER = 50). Samesystem_deployersgate asCancelTimer. For timers whose actor can no longer self-extend (bricked code, unreachable owner). Emitstimer.extended_by_governance.
current_height + TimerConfig.max_ttl_blocks
to prevent renewal past the unattended-lifespan ceiling. If the fire-time
self-destruct check has already debited fee_payer for the next fire’s
max_cost, the cancel path refunds the unused portion.
Note on governance routing. This CIP deliberately wires CancelTimer,
ExtendTimer, and UpdateTimerConfig (§6.4) as direct SystemInstruction
variants gated by system_deployers, rather than as payloads of a full
SubmitProposal → CastVote → ExecuteProposal flow. The validator set acts
as the “governance body” for these operations, matching the existing
UpdateBasefeeConfig / UpgradeActor pattern. A future revision can layer
a multi-block proposal flow on top if needed; the current design keeps the
emergency-response path fast.
6. Metering
6.1 Scheduling and Cancellation Costs
Cell costs for index metadata writes are charged separately by the storage layer per CIP-3. The
schedule_timer fee charges the scheduling transaction’s sender — this is independent of the timer’s fee_payer, which is only debited at fire time.
6.2 Execution Budget
Each timer handler execution receives a per-timer budget:
An actor may choose a smaller
gas_limit_per_fire to reduce the per-fire max cost (see §6.3). The cell limit is taken from config rather than from the timer for simplicity — over-reservation is refunded.
6.3 Fee Settlement
Timer execution is not free. Each fire is metered and paid for bytimer.fee_payer, with the same burn + tip breakdown as a normal user transaction:
schedule_timer (or extend_timer on the current timer before returning) from within itself. Each re-schedule is a fresh fire with a fresh max_cost check — this forms a pay-as-you-go subscription loop. When fee_payer runs dry, the subscription ends automatically on the next fire.
Worked example — system-actor self-funding (Storage Manager PoR-challenge timer)
A system actor funds its own recurring timer. The Storage Manager (STORAGE_MANAGER = 0x0A) re-arms its PoR-challenge timer with itself as fee_payer. Because fee_payer == actor_address, this takes the actor-pays-itself branch of §4.2 — the self-funding exemption that lets a system actor name itself (but never a different system actor). Each fire is pre-charged from 0x0A by the settlement algorithm above; CIP-9 §5.6 draws that budget from the 0x0B PoR challenge pool.
0x0A — or any 0x01..=0xFF reserved system address — as fee_payer; the exemption is restricted to fee_payer == actor_address (see §4.2). CIP-9 §5.6 covers the challenge timer’s funding source and the PorChallengePaused fallback when the pool is empty.
6.4 TimerConfig — Governed Parameters
Timer economics parameters are held in a governed config, stored under the basefee system actor using the same pattern as BasefeeConfig:
state:actor:{BASEFEE_SYSTEM_ACTOR}:kv:system:timer_config. Updated
via SystemInstruction::UpdateTimerConfig (opcode SYS_UPDATE_TIMER_CONFIG = 49)
with sender ∈ system_deployers — the same direct-governance pattern used
by CancelTimer / ExtendTimer (§5.4) and UpdateBasefeeConfig. The
constants.rs defaults apply when the stored config is absent. This allows
the validator set to raise TTL, lower per-fire caps, or throttle GC without
a chain upgrade and without going through a multi-block proposal flow.
Emits timer_config.updated with the serialized new config as event data.
6.5 Lane Budgets — Flow Control, Not Free Gas
Two independent block-level budgets bound total timer work per block:LANE_TIMER_CYCLES— aggregate cycles that fired timer handlers (path 1 in §5.4) may consume in one block. A timer whosegas_limit_per_fire(capped atper_fire_cycle_cap) exceeds the remaining lane budget is carried forward: it is removed from the current height index and re-bucketed atcurrent_height + 1(not merely left in place — an implementation that re-checks it at the same height would silently lose it), and askip_counton the timer record is incremented.skip_countis a serialized field of theTimer(§3.1) and therefore part of the state root. AfterTIMER_MAX_CARRY_FORWARD = 256consecutive carry-forwards the timer is dead-lettered — removed from storage with aTimerDeadLettered { timer_id, skip_count }event — so a permanently over-budget timer cannot accumulate forever. (This is a distinct removal mechanic from the §5.4 exit paths and from the execution-failure retry, which follows a separateretry_countup toMAX_TIMER_RETRY_BLOCKS = 3before dead-lettering.) The carried-forward timer still costs its owner nothing in the skipped block.TIMER_GC_CYCLES = TimerConfig.gc_cycles_per_block— a separate budget for removing expired and insufficient-funds timers (paths 2 and 3). This isolation prevents a resume-after-outage storm (manyexpires_at < current_heightat once) from crowding out live-timer execution.
fee_payer per §6.3.
7. Determinism & Replayability
The timer mechanism is fully deterministic:get_timers_by_height(h)depends only on the state of the timer store and height index at heighth.- Timer IDs are derived from on-chain data only (address, height, payload, nonce).
- Insertion order within a height bucket is determined by transaction execution order, which is consensus-critical.
- Classification (natural fire vs TTL expiry vs insufficient funds) is a pure function of
(current_height, timer.expires_at, balance(timer.fee_payer), max_cost)— all inputs are in consensus state. - Pre-charge/refund math (§6.3 steps 4–6) uses integer arithmetic over the basefee read from
BasefeeConfigat the block boundary; no floating-point, no locale-dependent formatting. TimerConfigis read once per block from consensus state; mid-block changes do not apply.- Local randomness, VRF, and wall-clock time are prohibited.
- On reorg, timer state (including pre-charge debits) rolls back with the QMDB state root, and delivery replays identically against the new parent state.
7.1 Inline Timer Manifest (block-digest commitment)
The set of timers fired at end-of-block is not only reflected in the state root — the proposer also commits an inline timer manifest directly into the block, and it is folded into the block digest preimage. An independent client MUST reproduce it exactly or it will compute a different block hash and fork.- The block carries
inline_timer_manifest_committed: boolandinline_timer_ids: Vec<timer_id>— the ordered list of timer IDs the proposer dispatched at EOB. - These fields are appended to the block digest preimage after the PresenceInput commitment and before
extra_data: theboolbyte, then a big-endianu64count, then eachtimer_idlength-prefixed with a big-endianu64. - The manifest is deterministic from state, not a proposer degree of freedom. On verify it MUST equal the canonical due-timer prefix:
inline_timer_ids[i] == get_timers_by_height(H)[i]for alli(the FIFO insertion order of §7 bullet 3), with length≤ MAX_INLINE_TIMER_MANIFEST_IDS = 4096and≤the deterministic admission capceil(gc_cycles_per_block / TIMER_GC_COST_PER_REMOVAL) + ceil(LANE_TIMER_CYCLES / per_fire_cycle_cap). A block whose manifest is missing, oversized, or not the canonical prefix is invalid.
8. Security Considerations
- Unbounded free execution: Prior to this CIP, timers executed under a
!is_system_triggeredbranch intransaction.rsthat skipped basefee deduction entirely. A single actor writing a few KVs per fire, paired with a deployer whose balance had dropped to zero, ran forever at the validator’s expense. §6.3 removes that branch: every fire is metered againstfee_payerand self-destructs when funds run out. - Timer storms: A malicious actor could schedule many timers for the same height, causing an EOB spike. Mitigations: per-timer scheduling cost (
SET_TIMER_BASE_CYCLES = 200cycles) +TimerConfig.max_timers_per_actorper-actor cap (default 1,024) +LANE_TIMER_CYCLESblock-level flow control. A future revision MAY add a globalMAX_FIRES_PER_BLOCK. - Resume-after-outage storm: If the chain halts for
Nblocks, on resume every timer withexpires_at < current_heightwould try to clear at once. §6.5 isolates expired/self-destruct clearing intoTIMER_GC_CYCLES, separate fromLANE_TIMER_CYCLES, so live-timer execution is not crowded out. Timers not cleared in one block roll to the next — still expired means still expired. cancel_timercross-actor cancellation (fixed by this revision): Thepvm_host.cancel_timer(timer_id)host call previously performed no ownership check, allowing any actor to append anytimer_idtocancelled_timersand silently delete another actor’s timer. §4.2 requirestimer.actor_address == executing_actorat the host-call layer; cross-actor cancellation is only possible via theCancelTimersystem instruction with explicit authorization (owner or governance, §5.4).- Fee-payer griefing / unauthorized fund drainage (closed by §4.2): An
earlier draft of this CIP allowed “any valid address” as
fee_payer, which would have let a hostile actor schedule timers with an arbitrary victim’s address — the block-level pre-charge (§6.3 step 4) would then drain the victim on every fire until depleted. §4.2 tightens the rule tofee_payerMUST be the executingactor_addressor the currenttx_sender; any other third-party address is rejected at schedule time. This guarantees every pre-charge has implicit consent: the actor is paying for its own work, or the tx signer explicitly chose to fund this timer by submitting the scheduling tx. “Third-party sponsorship with opt-in” is an enumerated future extension (§4.2). - Gas suicide: Because
gas_limit_per_fireis actor-chosen, an actor can schedule cheaply-priced timers. The scheduling fee +fee_payerbalance check at each fire prevents pure-spam: a zero-balancefee_payerproduces at most one self-destruct event per scheduled timer before removal. - Payload abuse: The 1 MiB payload limit prevents state bloat from oversized timer payloads.
- Reentrancy: Timer handlers execute in a fresh transaction context. They may schedule new timers, but those timers fire in future blocks only (same-block prohibition, §5.3).
- Deduplication: Timer IDs include the scheduling transaction’s nonce (§3.3), preventing duplicate registration from replayed transactions.
9. Target Design (Future) — see CIP-1 v3 Part III
The previous §9 specified a first-price auction with exponential bias as the future target. That mechanism is superseded by CIP-1 v3 Part III (EIP-1559 timer-lane basefee + priority tip + per-actor fairness weight), which is the canonical target design for post-FIFO timer scheduling. CIP-5 retains §§1–8 as the canonical specification of the currently implemented FIFO behaviour until CIP-1 v3 activates. When CIP-1 v3 ships, theschedule_timer host API gains (max_fee_per_cycle, max_priority_fee_per_cycle) parameters in lieu of the deprecated bid field; the per-fire fee_payer model in §6.3 remains binding (priority tip extends the max_cost formula but does not change pre-charge / refund mechanics). See CIP-1 v3 Part III §7 for the migration table.
Appendix A: End-of-Block Timer Delivery Sequence
Appendix B: Recurring Timer Pattern
Since timers are one-shot, actors implement recurring behavior by re-scheduling from within the handler. Each re-schedule is a fresh pay-as-you-go subscription tick — whenfee_payer runs dry, the loop terminates automatically on the next fire (§5.4 path 2).
Deployer-funded variant
When the deployer wants to fund a long-lived service actor (e.g. a pricing oracle), callschedule_timer_ex from within the deploy handler and pass
the deployer’s address as fee_payer. Because deploy runs as part of the
deployer-initiated tx, tx_sender == deployer — the fee_payer check in
§4.2 accepts this value. Scheduling the same timer with fee_payer = self.deployer from a handler invoked by someone OTHER than the deployer
would be rejected, which is the intended security boundary.

