The Standard Stack
Most DAO treasuries run on three pieces that never touch each other directly:
- Snapshot: off-chain voting. Weighted by token balance at a snapshot block. Costs nothing, binds nothing.
- Timelock: the delay between a proposal being approved and its execution. Usually 24 to 72 hours.
- Safe: the executor. A multisig that holds the funds and only moves them through the timelock.
The design separates who decides from who signs. Votes happen off-chain. The multisig signs what the timelock schedules. The timelock delays what the multisig wants.
Why the Delay Exists
The delay exists so that members can react. If a proposal is malicious or plainly wrong, the window is the time to exit or to change the guard. A treasury without a delay is a treasury where a single vote round can drain everything before anyone notices.
The delay is also a governance speed limit. It converts "we voted for this" into "we still agree with this after sleeping on it."
A Minimal Timelock
The real timelock used by many DAOs is OpenZeppelin's TimelockController. Its core loop is short:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MinimalTimelock {
uint256 public minDelay;
mapping(bytes32 => uint256) public scheduled;
constructor(uint256 _minDelay) {
minDelay = _minDelay;
}
function schedule(address target, bytes calldata data) external {
bytes32 id = keccak256(abi.encode(target, data, block.timestamp));
scheduled[id] = block.timestamp + minDelay;
}
function execute(address target, bytes calldata data) external {
bytes32 id = keccak256(abi.encode(target, data, block.timestamp));
uint256 readyAt = scheduled[id];
require(readyAt != 0, "not scheduled");
require(block.timestamp >= readyAt, "not ready");
delete scheduled[id];
(bool ok, ) = target.call(data);
require(ok, "execution failed");
}
}
Note what is missing: access control. In production the scheduling and execution functions are role-gated, and the timelock itself is usually a proposal target so governance can change its own parameters.
Wiring It Together
The flow for a real payout, from a script:
import { ethers } from "ethers";
import { Safe } from "@safe-global/protocol-kit";
// 1. Snapshot vote passes. The payout is now "approved".
// 2. The timelock schedules the transfer.
const timelock = new ethers.Contract(timelockAddress, timelockAbi, signer);
await timelock.schedule(
treasuryAddress,
timelock.interface.encodeFunctionData("executeTransfer", [recipient, amount])
);
// 3. After minDelay, the Safe signs the execution call.
const safe = await Safe.init({ provider, signer, safeAddress: treasuryAddress });
const tx = await safe.createTransaction({
transactions: [{
to: timelockAddress,
data: timelock.interface.encodeFunctionData("execute", [
timelockAddress,
timelock.interface.encodeFunctionData("executeTransfer", [recipient, amount]),
]),
value: "0",
}],
});
const receipt = await safe.executeTransaction(tx);
Three signatures may be involved: the Safe threshold, plus whoever submits the timelock calls. That is fine. Friction is the product.
Failure Modes
- Expired proposals: most timelocks expire after a window (for example 14 days). A proposal past its deadline must be rescheduled, which is another governance vote. Budget for this.
- Guard changes: if the multisig can bypass the timelock, the delay is theater. The standard setup gives the timelock the execution role and nothing else.
- Spam: anyone can schedule arbitrary calls in some configurations. Rate-limit scheduling and require a whitelisted target set for small DAOs.
What a Small DAO Should Actually Run
For a treasury under $1M: a 2-of-3 Safe, a 48-hour timelock, Snapshot voting with a simple token gate. That covers accidental and malicious transfers without hiring anyone. Fancy governance modules earn their complexity only when the treasury size justifies it.