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):
- The foundation operator deploys
CryptoRegistry(init: owner = foundation operator). - The foundation operator deploys
Timelock(init: owner = foundation operator, delay = 14d). - 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 callsregistry.set_scheme(id, addr)with the Timelock ascaller(). - 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.