SourceLessExplorer · SourceLess AsgardSourceLess Asgard · chain 1313
LiveHeight23,717Head age46 sLast interval1 minFinality23,706Behind−11Pending0 / 8,192Tx in head0
XPlorer following the chain as its own read-only node

height 23717 of 23717 on the network. 9 peer(s) connected, 77 in its peer directory; 1013 block(s) followed and 842 pulled in by sync since this follower started. Caught up: this follower holds every block any peer has claimed, so every panel below is the whole chain. Genesis 1cf8655125cc1bbb…, producers genesis.

What this follower is, locally

Following 7 seed(s) — their addresses are not published here, for the reason the peer table gives. Its store is held under its own Cerberus lease — no other node’s store is opened, touched or stopped.

Timelock for your fork

Status
a draft, not deployed nothing here is on any chain; never send value to a draft
Profile
native STR timelock
Holds
native STR
The property that matters
nothing pays sooner than the declared delay, and after it the owner cannot stop it

Edit the draft

Set the constants the template marks for editing, and name a deployer to see where it would land.

uint, shipped as 172800000

uint, shipped as 604800000

The form asks this explorer to compile your draft and show it back. It signs nothing, stores nothing and reaches no node.

The draft as shipped

Contract
Timelock
Compiler
areslang-1.3.0
Bytecode SHA-256
d04e62eecbcd4ca5c508a26b3421fc8be90cc3a35bd11be1cfd3c1e7e8392d8c
Bytecode
1,888 bytes
Source SHA-256
3671ea1978795bbabdeaecfeb21fff545b587443f9f8d6163a3b24813de2ec40
The catalogue text?
Yes, byte for byte: matches the published vector
Ready to deploy?
no catalogue examples left
Would land at
name a deployer to see it

Deploy it to your fork

Only the last step signs, and only it reaches a node.

  1. Save the draft. Download it above, or start from the catalogue text with strblk contract template timelock > timelock.ares and edit the marked constants yourself.
  2. Dry-run it on your machine. strblk wallet deploy timelock.ares --dry-run --address <your address on the fork> compiles it locally and prints its digest and address. They must match this page; if they do not, the file is not this draft.
  3. Find your fork's RPC. It is the rpc (Iris, wallets) line of the fork's banner, and the rpcUrl of each node in <data root>/fork.json.
  4. Deploy it. strblk wallet deploy timelock.ares --node <your fork's RPC>, from a wallet whose account holds a proved STR.domain on your fork. Before it signs, the wallet prints the network: the realm reads unregistered and the genesis is your fork's hash. Check that hash; it is what the signature binds.

Measured gas

Charged by the STARW VM under areslang-1.3.0, the same on your fork. Measured, not estimated.

Entry pointGas
execute3,264
queue1,452
cancel517
Declare at least
32,640 ten times the most expensive call

Functions

KindNameInputsOutputsMutability
functiondelayuintview
functiongraceuintview
functionhelduintview
functionoperationCountuintview
functionrecipientOfid: uintaddressview
functionamountOfid: uintuintview
functionetaOfid: uintuintview
functionstatusid: uintuintview
functionexecutableid: uintboolview
functionexpiredid: uintboolview
functionowneraddressview
functionpendingOwnerIsaddressview
functionqueueto: address, amount: uintuintnonpayable
functioncancelid: uintnonpayable
functionexecuteid: uintuintnonpayable
functionproposeOwnernext: addressnonpayable
functionacceptOwnershipnonpayable
eventQueuedid: uint, to: address, amount: uint, etaMs: uint
eventCancelledid: uint, by: address
eventExecutedid: uint, to: address, amount: uint, by: address
eventOwnerProposedcurrent: address, pending: address
eventOwnerChangedprevious: address, next: address

Your draft's source

templates/timelock.ares, as shipped

// =========================================================================================
// 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<uint, address>;
  state opAmount: map<uint, uint>;
  state opEta: map<uint, uint>;
  // 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<uint, uint>;

  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 = "";
  }
}