// ========================================================================================= // Timelock — every payment announced before it can happen, with a delay nobody can shorten. // // WHAT IT IS FOR // Holding native STR under the rule that the owner may only pay it out AFTER a declared // waiting period. That is what a timelock is for on every chain: not to stop a payment, but // to make it impossible for one to happen faster than the people affected can see it coming // and react — move their funds, sell, complain, call a lawyer, fork the front end. // // The delay is fixed at deployment. There is no `setDelay`, and there could not be a useful // one: an owner who can shorten the delay has no delay, and an owner who can shorten it after // a proposal is queued has a timelock that exists only while nobody minds. // // THE THREE PROPERTIES THAT MAKE IT A TIMELOCK // 1. QUEUE IS PUBLIC AND EARLY. `queue` writes the recipient, the amount and the earliest // execution time into storage and emits them. Every one is readable by anyone from the // moment it is queued, which is the entire security model: a timelock protects nobody if // the queue is private. // 2. THE OWNER LOSES CONTROL AT THE DEADLINE. `cancel` requires `timestamp < eta`. Once the // wait is over the owner cannot stop the payment, and `execute` may be called BY ANYBODY // — so a beneficiary is not at the mercy of an owner who has gone quiet. Before the // deadline the owner may cancel freely; that is what the waiting period is for. // 3. AN OLD OPERATION DIES. `execute` refuses after `eta + grace`. Without that, a payment // queued and forgotten two years ago could be executed by anyone at any moment — a // timelock is a delay, not an indefinite standing authority. // // WHAT IT GUARANTEES // * No operation can execute earlier than `delay()` milliseconds after the block it was // queued in. The time comes from the SEALED BLOCK's timestamp, never from a caller // argument and never from a node's clock, so no caller can move it and every node agrees. // * An operation executes at most once. The status moves 1 (queued) to 2 (executed) before // the transfer, and both a second `execute` and a late `cancel` refuse on it. // * A cancelled operation can never execute, and a cancellation cannot be undone: there is // no path back from status 3. // * The owner cannot change a queued operation's recipient or amount. There is no setter; // an amended payment is a new operation with a new delay, which is the point. // * The owner cannot pay themselves faster than anybody else. There is no privileged path // and no emergency withdrawal — read the ABI: `queue`, `cancel`, `execute` and the // ownership handover are all of it. // * A revert leaves the queue and the balance byte-identical. Storage and value commit // together, only on success. // // WHAT IT CANNOT DO, AND WHY THAT MATTERS HERE MORE THAN ELSEWHERE // A timelock on other chains usually guards a CALL: it queues "call `setFee` on that // contract" and executes it later. THIS CANNOT DO THAT. There are no cross-contract calls in // AresLang, so the only operation expressible is a payment of native STR out of this // contract's own balance. A timelocked governance layer over another contract is not // writable today, and no arrangement of this code makes it so. // // It also cannot see who funded it: a call record carries no value and there is no deposit // hook, so the STR in this contract is pooled and belongs to whatever the owner queues. Fund // it by an ordinary transfer to its address, AFTER deployment — the receive gate answers from // the deployed set, so an address that does not exist yet cannot be funded. // // HOW IT IS TYPICALLY MISUSED // * Queuing an operation the contract cannot afford, and reading the queue as a promise. // Solvency is checked at EXECUTION, not at queue time, because the money may legitimately // arrive in between. An operation queued against an empty contract reverts with // `insufficient balance` when its time comes, and stays queued until it expires. // * Setting a delay of a few seconds to "get the pattern in place". A delay nobody can act // within is decoration. Pick the time the affected parties would actually need. // * Setting a grace window shorter than the parties can be relied on to be awake. If nobody // executes inside it the operation expires and must be queued again, delay and all. // * Forgetting that `execute` is open to anyone. That is deliberate — it is what stops an // owner censoring a payment by inaction — but it means a payment WILL land the moment // anybody bothers, not at a moment the owner picks. // * Under-declaring the gas limit on the call record. Post-mining covers only // min(gasUsed, gasLimit / 10), so the network silently absorbs the rest, forever, on every // call. MEASURED, on the v2 gas table, with 53-character addresses: queue 1,452, // cancel 517, execute 3,264 — of which 2,100 is the `TRANSFER` itself and 400 the balance // read — proposeOwner 412, acceptOwnership 612. Declare a gas limit of at least 33,000. // // TO USE IT // Edit the two constants below — they are the whole policy — deploy, fund the address by an // ordinary transfer, then `queue(to, amount)`, wait, and `execute(id)`. // ========================================================================================= contract Timelock { // ---- edit these two ------------------------------------------------------------------ // The waiting period, in milliseconds. 172800000 is 48 hours. Immutable once deployed: see // the header for why a setter would defeat the contract. state delayMs: uint = 172800000; // How long after the waiting period an operation stays executable. 604800000 is 7 days. // After that it expires and has to be queued again from the start. state graceMs: uint = 604800000; // ------------------------------------------------------------------------------------- // `caller` inside a state initializer is the DEPLOYER: Sindri runs the compiler-generated // initializer with the authenticated deployer as caller, so there is no claim race. state contractOwner: address = caller; state pendingOwner: address = ""; // Ids are handed out from here, start at 1, and are never reused. state opCount: uint = 0; // One operation, spread across four maps because AresLang has no struct. All written by // `queue` and, apart from the status, never written again. state opTo: map; state opAmount: map; state opEta: map; // 0 no such operation, 1 queued, 2 executed, 3 cancelled. Plain numbers rather than an enum // because AresLang has none; the codes are part of the ABI contract with a client and are // documented on `status()` below. state opStatus: map; event Queued(id: uint, to: address, amount: uint, etaMs: uint); event Cancelled(id: uint, by: address); event Executed(id: uint, to: address, amount: uint, by: address); event OwnerProposed(current: address, pending: address); event OwnerChanged(previous: address, next: address); // ---- views ---------------------------------------------------------------------------- view fn delay() -> uint { return delayMs; } view fn grace() -> uint { return graceMs; } // What the contract holds right now, read from the native ledger rather than from a mirror // in state: a cached balance is a second copy of the truth and diverges the moment anybody // pays this contract without calling it. view fn held() -> uint { return balance(self); } view fn operationCount() -> uint { return opCount; } view fn recipientOf(id: uint) -> address { return opTo[id]; } view fn amountOf(id: uint) -> uint { return opAmount[id]; } // The earliest block time at which this operation may execute. Absolute milliseconds, so a // reader needs nothing but this number and their own clock. view fn etaOf(id: uint) -> uint { return opEta[id]; } // 0 no such operation, 1 queued, 2 executed, 3 cancelled. A status of 1 does not mean // executable — see `executable(id)`, which also accounts for the wait and the grace window. view fn status(id: uint) -> uint { return opStatus[id]; } // Everything `execute` checks except the balance, which a client should read separately with // `held()` so that "not yet" and "not funded" are distinguishable. view fn executable(id: uint) -> bool { return opStatus[id] == 1 && timestamp >= opEta[id] && timestamp <= opEta[id] + graceMs; } // True once the window has closed on a queued operation that nobody executed. It stays // status 1 forever — there is no sweeper to mark it, because a contract has no way to act on // its own without a caller. view fn expired(id: uint) -> bool { return opStatus[id] == 1 && timestamp > opEta[id] + graceMs; } view fn owner() -> address { return contractOwner; } view fn pendingOwnerIs() -> address { return pendingOwner; } // ---- the queue --------------------------------------------------------------------------- fn queue(to: address, amount: uint) -> uint { require(caller == contractOwner, "not owner"); // Unreachable through a call record — `validateArgs` checksum-validates every `address` // argument before the first opcode — and kept for a future caller path that does not. require(len(to) > 0, "empty recipient"); require(amount > 0, "amount must be positive"); // Paying itself would move nothing and could never be spent: a contract cannot be // `caller`, so value at this address is stuck here whatever the queue says. require(to != self, "self is not payable"); let id = opCount + 1; opCount = id; opTo[id] = to; opAmount[id] = amount; // The deadline is computed from the BLOCK's timestamp, which is the one clock every node // agrees on. Nothing in this contract can move it afterwards. opEta[id] = timestamp + delayMs; opStatus[id] = 1; emit Queued(id, to, amount, opEta[id]); return id; } // The owner's veto, and it has a deadline. After `eta` this refuses — which is the whole // difference between a timelock and a promise. fn cancel(id: uint) { require(caller == contractOwner, "not owner"); require(opStatus[id] == 1, "not queued"); require(timestamp < opEta[id], "wait is over"); opStatus[id] = 3; emit Cancelled(id, caller); } // Callable BY ANYONE once the wait is over, so an owner cannot censor a payment by going // quiet. Checks, then effects, then the interaction: the status is written to 2 before the // transfer, so even on a chain that had reentrancy — this one does not, there is no call // opcode — a second execution could not be reached. fn execute(id: uint) -> uint { require(opStatus[id] == 1, "not queued"); require(timestamp >= opEta[id], "still waiting"); // Expiry, so a forgotten operation is not a standing authority to move money years later. require(timestamp <= opEta[id] + graceMs, "operation expired"); let amount = opAmount[id]; // Checked here rather than at queue time because the funds may legitimately arrive in // between. The explicit check is about the MESSAGE: without it the revert comes from the // ledger refusing the transfer, and says nothing about which of the two problems it was. require(balance(self) >= amount, "insufficient balance"); opStatus[id] = 2; emit Executed(id, opTo[id], amount, caller); transfer(opTo[id], amount); return amount; } // ---- ownership -------------------------------------------------------------------------- // Two steps, because a one-step handover to a mistyped address is permanent: there is no // administrator anywhere in SourceLess who can reach into a contract's slots. Note what the // handover does NOT do: operations already queued keep their recipients, amounts and // deadlines, and the new owner inherits the same deadline-bounded veto as the old one. fn proposeOwner(next: address) { require(caller == contractOwner, "not owner"); pendingOwner = next; emit OwnerProposed(contractOwner, next); } fn acceptOwnership() { require(caller == pendingOwner, "not the pending owner"); emit OwnerChanged(contractOwner, caller); contractOwner = caller; pendingOwner = ""; } }