Skip to content

Timelock

Source: fourier/stdlib/timelock.fou.

An owner-controlled gate with a mandatory delay between queueing and executing proposals. Any caller can execute a proposal once its eta passes (so an unavailable owner does not block upgrades); the owner can also cancel before execution.

Storage

Slot Name Type Purpose
0 owner address Sole proposer / canceller
1 delay uint Min seconds between queue and earliest execution
2 grace uint Expiry window after eta
3 next_id uint Auto-incrementing proposal id
4 p_target map[uint, address] Target contract
5 p_value map[uint, uint] WAVE to forward
6 p_selector map[uint, uint] Selector byte for the call
7 p_arg map[uint, uint] Single argument (uint)
8 p_eta map[uint, uint] Earliest execution timestamp
9 p_executed map[uint, uint] 0/1
10 p_cancelled map[uint, uint] 0/1

Reserves slots 0–10. Inheriting contracts must start their storage at slot 11 or higher.

Source highlights

fn init() {
    owner = caller();
    delay = 1209600;       // 14 days in seconds
    grace = 1209600;       // 14 days grace window
}

pub fn queue(target: address, value: uint, selector: uint, arg: uint,
              eta: uint) -> uint {
    require(caller() == owner);
    require(eta >= timestamp() + delay);
    // ... store proposal fields, emit, return id ...
}

pub fn cancel(id: uint) {
    require(caller() == owner);
    require(p_executed[id] == 0);
    require(p_cancelled[id] == 0);
    p_cancelled[id] = 1;
    emit ProposalCancelled(id);
}

pub fn execute(id: uint) -> uint {
    require(p_cancelled[id] == 0);
    require(p_executed[id] == 0);
    require(timestamp() >= p_eta[id]);
    require(timestamp() < p_eta[id] + grace);
    p_executed[id] = 1;
    let cd: bytes = pack_sel(p_selector[id], p_arg[id]);
    let ok: uint = call_b(p_target[id], cd, p_value[id], 500000);
    require(ok == 1);
    emit ProposalExecuted(id);
    return 1;
}

(Full source at fourier/stdlib/timelock.fou.)

Selectors

Selector Function
0x01 queue(address, uint, uint, uint, uint) -> uint
0x02 cancel(uint)
0x03 execute(uint) -> uint
0x04 transfer_ownership(address)
0x05 get_owner() -> address
0x06 get_delay() -> uint
0x07 get_proposal_eta(uint) -> uint
0x08 is_executed(uint) -> uint
0x09 is_cancelled(uint) -> uint

Lifecycle

queue(target, value, sel, arg, eta)
  ├── owner-only
  ├── require eta >= now + delay
  └── stores proposal, emits ProposalQueued

(wait until eta)

cancel(id)   ─── owner-only, before execute
execute(id)  ─── anyone, between eta and eta + grace

The delay enforces a minimum wait. The grace window prevents indefinite execution: if no caller invokes execute within grace seconds after eta, the proposal silently expires. The next execute(id) then fails the upper-bound check timestamp() < eta + grace.

init runs at deploy

init() sets the deployer as owner and initializes delay and grace to 14 days (1,209,600 seconds) each. These are constants; different values require source changes before compiling.

init is private and runs atomically inside the deploy transaction. See Contracts / init.

One-arg constraint

Proposals carry a single 32-byte argument because the contract defines only one p_arg mapping. This covers most upgrade-flavored calls:

  • transfer_ownership(addr) — one argument.
  • set_scheme(id, addr) — two arguments; requires packing both into one word (for example, (id << 160) | addr) or extending the contract to store a second arg mapping.
  • Arbitrary multi-arg calls — extend the contract to store a bytes-hash commit/reveal scheme. Out of scope here.

Governance pattern: Timelock + CryptoRegistry

The canonical governance setup (per the CryptoRegistry docstring):

  1. The foundation operator deploys CryptoRegistry (init: owner = foundation operator).
  2. The foundation operator deploys Timelock (init: owner = foundation operator, delay = 14d).
  3. The foundation operator calls registry.transfer_ownership(timelock_address).

After step 3:

  • The registry's owner is the Timelock.
  • The foundation operator queues set_scheme(id, addr) proposals in the Timelock.
  • After the 14-day delay, the foundation operator (or any caller) invokes timelock.execute(id), which calls registry.set_scheme(id, addr) with the Timelock as caller().
  • The registry's require(caller() == owner) passes because the Timelock is the owner.

Self-only ownership transfer

pub fn transfer_ownership(new_owner: address) {
    require(caller() == owner);   // shortcut for v1; harden later
    owner = new_owner;
    emit OwnerTransferred(new_owner);
}

The source comment reads "shortcut for v1; harden later." A production-grade Timelock would require caller() == address(this); ownership transfer would itself need to be queued and timelocked. This version trusts the owner to act correctly.

Anybody-can-execute

After eta, any address can call execute(id). This is by design: queued proposals proceed even when the owner becomes unavailable. The trade-off is the loss of the owner's veto window after eta. For owner-only execution, gate execute with require(caller() == owner).

Inherit-by-copy notes

Timelock is large (10+ storage slots, 9 public functions). Most deployments use Timelock as a separate deployed contract rather than inheriting it: the foundation operator deploys one Timelock and points each governable contract at the same Timelock address.

Under inherit-by-copy, renumber all @ slots to sit above the consuming contract's own storage.