dao.new specification

Julien Béranger

+ Claude Opus 5.5

Summary

dao.new lets any online service give real decision power to its community in one API call. The service sends either a list of email addresses or a number of members. The relayer deploys a Gov DAO and returns its address with one claim code per member. Each code claims one non-transferable membership NFT, which carries one vote.

The service then sends part of its budget (1%, 5% or more, there is no cap) to the DAO. From that point the money is controlled by the community's votes, not by the service. The DAO can also vote on the state of a piece of data: a content hash, a website build hash, a manifesto.

Members need no crypto knowledge and no ETH. They sign in with a passkey through w3pk, and the relayer pays gas for them. The relayer can never act in a member's name: every action is a signature only the member can produce, so the relayer is dispensable.

Use case

A legacy online service (a forum, a media site, an open-source project, a cooperative platform) wants to share governance without migrating its users to a crypto product.

  1. The service creates a DAO on dao.new with its community's emails, or a member count if it distributes codes itself.
  2. Members claim their membership from the email link.
  3. The service funds the DAO's treasury.
  4. Members propose and vote on spending, and on the state of data the service commits to respect (for example: "the production site must match build hash X").

Because the result is on-chain, the service's commitment becomes checkable by anyone, not only by the community.

Components

ComponentRoleStatus
GovGovernor and membership NFT, based on OpenZeppelin GovernorExists; one extension needed
DaoFactoryDeploys and wires one DAO per call, atomicallyNew
ClaimOperatorGov's operator; mints for each valid claim code once, and for the service's admin key within a capped rateNew
Admin helperA funded key and a small script the service uses to add members who join laterNew
StateRegistryKey-to-hash store that only the DAO can writeNew
WulongRelayer API running in a TEE on Phala CloudExists; relayer endpoints needed
GenjiNext.js UI with passkey wallets and WCAG 2.1 AA accessibilityExists; dao.new pages needed

Default chain: Base, which Wulong already uses for anchoring. Crosschain Gov is a later option for communities on other chains.

On-chain design

What Gov already provides

Gov's NFT.sol and Gov.sol cover most requirements as they are:

  • One member, one vote. _mint reverts with OneMemberOneVote if the address already holds a membership.
  • Non-transferable. _update reverts with NFTNonTransferable on any transfer; only mints and burns pass.
  • Automatic self-delegation. Every mint path calls _delegate(to, to), so a new member's vote counts without any action from them.
  • Operator role. operatorMint(to, uri) is callable by operator until operatorExpiration. Both are set by the NFT's owner (the Governor).
  • Governance-controlled membership. safeMint, govBurn and setMetadata are owner-only, so after the claim window only votes can add or remove members.
  • Manifesto. setManifesto stores an IPFS CID, changeable only through governance.
  • Treasury. There is no timelock: the Governor executes proposals itself, so the Governor's address is the treasury.
  • Timestamp clock. clock() returns block.timestamp, so voting delay and period are in seconds.
  • Quorum. GovernorVotesQuorumFraction sets quorum as a fraction of the NFT's total supply at the proposal snapshot. Unclaimed codes don't count toward it.

DaoFactory

The NFT and the Governor reference each other: the Governor needs the NFT address in its constructor, and the NFT needs an owner that will be the Governor. The factory resolves this in one transaction.

function createDao(DaoParams calldata p) external returns (address gov, address nft, address claim, address registry);

Sequence inside createDao:

  1. Deploy NFT with initialOwner = address(this) and an empty _firstMembers list.
  2. Deploy Gov with the NFT as its token and the parameters from p.
  3. Deploy ClaimOperator with the NFT address, the Governor as owner, the codes' Merkle root, the tree's IPFS CID, the claim deadline, and the admin settings (admin address, cap, period, expiry).
  4. Call nft.setOperator(claim) and nft.setOperatorExpiration(max(p.claimDeadline, p.adminExpiry)). The NFT has a single operator expiry, so it is set to the later of the two; ClaimOperator enforces each path's own deadline.
  5. Deploy StateRegistry with owner = gov.
  6. Call nft.transferOwnership(gov).
  7. Emit DaoCreated(gov, nft, claim, registry, p.name).

Once createDao returns, the factory holds no power over the DAO. Anyone can call it, so the relayer is not required for deployment either.

Gov is deployed in full each time, not as an ERC-1167 minimal-proxy clone. On Base this costs little per DAO; clones can come later if volume justifies the work of making Gov initializable.

ClaimOperator and claim codes

The claim flow has to resist front-running: if a claim transaction contained the code in plain text, anyone watching pending transactions could copy it and claim first. So a code is never revealed on-chain.

Code generation (off-chain, by the relayer):

  1. For each member, draw 16 random bytes: the code.
  2. Derive a private key: codeKey = keccak256(abi.encodePacked("dao.new", chainId, factoryAddress, nonce, code)), where nonce is the deploy's unique request ID. The code's address is codeAddress = address(codeKey).
  3. Build a Merkle tree whose leaves are the codeAddress values, using OpenZeppelin's merkle-tree library.
  4. Publish the tree (addresses only, nothing secret) to IPFS. Its CID is stored in ClaimOperator, so any client can rebuild a proof without the relayer.
  5. Encode each code in Base58 (about 22 characters) and return them, or email them as https://dao.new/claim/<dao>#<code>. The code sits in the URL fragment, which browsers never send to the server.

The derivation string ties every code to one DAO, so a code from one DAO is useless on another. 128 bits of randomness put brute-forcing the published addresses out of reach.

Claiming (on-chain):

function claim(address recipient, address codeAddress, bytes32[] calldata proof, bytes calldata signature) external;

claim checks that:

  • the claim deadline has not passed;
  • codeAddress is a leaf of the stored root (proof);
  • codeAddress has not been used;
  • signature is a valid EIP-712 signature by codeAddress over Claim { address nft; address recipient; }, with the chain ID in the domain.

It then marks the code used and calls nft.operatorMint(recipient, tokenUri). The NFT itself rejects a recipient who already holds a membership.

Because the signature commits to recipient, a copied transaction can only ever mint to the intended member. Anyone may submit it, so a member with ETH never depends on the relayer.

Why this neutralizes Gov's operator warning: Gov's README warns that an operator can mint arbitrarily many memberships until expiry. Here the operator is a contract that can only mint for codes committed at deploy time, each once, until the claim deadline, plus the capped admin path below, which the DAO can shrink or shut off.

Admin minting helper

Communities keep growing after the claim window. Without help, each new member would need a governance vote (safeMint). The admin helper lets the service add new users directly, within limits the DAO controls.

What the service gets: an admin key (an ordinary Ethereum private key, held by the service as a hot key) funded with a small amount of ETH for gas, and a script that mints a membership to a new user's address.

Why it goes through ClaimOperator: Gov's NFT has a single operator slot, already taken by ClaimOperator. So the admin path lives in the same contract, which lets the contract apply limits that a raw operator key would not have:

address public admin;
uint256 public adminCap;        // mints allowed per period
uint256 public adminPeriod;     // in seconds
uint256 public adminExpiry;     // timestamp; 0 = no expiry

function adminMint(address to) external;                                  // called by the admin key itself
function adminMintBySig(address to, uint256 nonce, bytes calldata sig) external; // admin signs, relayer submits
function setAdmin(address newAdmin) external onlyOwner;                    // owner = Governor
function setAdminLimits(uint256 cap, uint256 period, uint256 expiry) external onlyOwner;

adminMint checks that the caller is admin, that adminExpiry has not passed, and that fewer than adminCap mints happened in the current period (block.timestamp / adminPeriod). It then calls nft.operatorMint(to, tokenUri). adminMintBySig does the same from an EIP-712 signature AdminMint { address to; uint256 nonce; }, so a service can keep the key unfunded and let the relayer pay gas.

Joining flow:

  1. A new user signs up on the service.
  2. The service sends them to https://dao.new/join/<dao>, where Genji creates a passkey wallet and returns its address to the service (redirect or callback URL).
  3. The service runs the helper with that address.
  4. The user's membership appears; they can vote on proposals whose snapshot falls after the mint.

The helper script (ethers v6):

import { Contract, JsonRpcProvider, Wallet } from "ethers";

const provider = new JsonRpcProvider(process.env.BASE_RPC_URL);
const admin = new Wallet(process.env.DAO_ADMIN_KEY!, provider);
const claim = new Contract(
  process.env.DAO_CLAIM_ADDRESS!,
  ["function adminMint(address to)"],
  admin,
);

export async function addMember(to: string) {
  const tx = await claim.adminMint(to);
  return (await tx.wait())?.hash;
}

Shipped as a small npm package (name to be decided) with a CLI (mint <address>) and a balance check that warns when the key runs low on gas.

Who generates the key: the service, locally. It passes only adminAddress to POST /daos, so the relayer never sees the private key. The UI's create page can generate one in the browser and offer it as a download.

Limits and why they matter: this path reintroduces part of the risk Gov's README warns about. A leaked or misused admin key could mint members to swing a vote: anyone minted before a proposal's snapshot can vote on it. The safeguards:

  • Rate cap. At most adminCap mints per adminPeriod, so a leaked key can add only a bounded number of votes before the DAO reacts.
  • Expiry. The admin path stops at adminExpiry unless the DAO renews it.
  • Revocation by vote. The DAO can call setAdmin(address(0)), tighten setAdminLimits, or remove the whole operator with nft.setOperator(address(0)).
  • Visibility. Every admin mint emits AdminMinted(to, tokenId); the DAO page shows recent admin mints next to open proposals so members can see a sudden wave.
  • Undo. Members minted improperly can be removed with govBurn by vote, as long as honest members still hold a majority.

The cap should stay small relative to the membership: a cap that lets the admin outnumber active voters within one voting delay defeats the point. The UI should warn when adminCap exceeds a few percent of current supply.

Gov extension: proposeBySig

OpenZeppelin's Governor already accepts relayed votes through castVoteBySig and castVoteWithReasonAndParamsBySig. It has no signature-based propose: propose checks the proposal threshold against msg.sender. Without a change, a member with no ETH could vote but not propose.

Add a GovProposeBySig extension to Gov:

function proposeBySig(
    address[] memory targets,
    uint256[] memory values,
    bytes[] memory calldatas,
    string memory description,
    address proposer,
    bytes memory signature
) public returns (uint256);

It verifies an EIP-712 signature by proposer over the proposal's contents and a nonce (reusing Governor's Nonces), checks the threshold against proposer's votes, and calls the internal _propose(..., proposer).

GovProposalTracking records proposal IDs in its public propose override, so proposeBySig would bypass it as written. Move the tracking into an override of _propose so both paths record the ID.

The alternative, setting the proposal threshold to 0 and letting the relayer call propose, works without contract changes but records the relayer as every proposal's author and lets anyone spam proposals. The extension is the recommended route.

StateRegistry

The manifesto field covers one document. For other data the DAO decides on, a separate contract owned by the Governor keeps Gov minimal:

mapping(bytes32 key => bytes32 value) public state;
event StateUpdated(bytes32 indexed key, bytes32 oldValue, bytes32 newValue, uint256 proposalId);
function setState(bytes32 key, bytes32 value) external onlyOwner;

Keys are keccak256 of a human-readable name ("website.build", "terms.v2"). The service, or any third party, can read state(key) to check that what it serves matches what the community approved. Proposals reach it like any other call, since Gov executes arbitrary calldata.

Relayer API

The relayer is Wulong extended with dao.new endpoints. Wulong's SIWE authentication identifies the service creating a DAO; members authenticate only through their own signatures, which the contracts verify.

MethodPathCallerDoes
POST/daosService (SIWE)Generates codes, publishes the tree, calls createDao, returns the DAO and codes
GET/daos/:govAnyoneDAO addresses, parameters, claimed count, treasury balance
POST/claimsMemberSubmits a signed claim
POST/proposalsMemberSubmits a signed proposeBySig
POST/votesMemberSubmits a signed castVoteBySig
POST/executionsAnyoneExecutes a passed proposal
POST/admin-mintsServiceSubmits a signed adminMintBySig, for services that keep their admin key unfunded

POST /daos request:

{
  "name": "Example Community",
  "symbol": "EXC",
  "members": ["alice@example.com", "bob@example.com"],
  "manifestoCid": "bafy...",
  "votingDelay": 86400,
  "votingPeriod": 604800,
  "quorum": 20,
  "claimDays": 30,
  "admin": {
    "address": "0x...",
    "cap": 10,
    "periodDays": 7,
    "expiryDays": 365
  }
}

members is either an array of emails or an integer. admin is optional; without it, new members join only by vote. Response:

{
  "gov": "0x...",
  "nft": "0x...",
  "claim": "0x...",
  "registry": "0x...",
  "treeCid": "bafy...",
  "codes": ["7Hq2...", "..."]
}

When emails are given, codes is omitted: the relayer emails each link and forgets both lists. Codes are returned once and never stored.

Relayer constraints to plan for

  • Rate limiting. Wulong limits each route to 10 requests per minute per client IP. With TLS terminating in the enclave, every client shares the gateway's IP, so a DAO voting at once would hit one shared limit. Relayed member routes need either a higher, separate limit or per-DAO queuing.
  • Gas budget. Wulong caps its relayer wallet's balance (0.01 ETH by default). Deploys and sponsored transactions need a funding policy: per-service prepaid credit, a per-DAO allowance, or a fee at deploy.
  • Abuse. Free deploys and free proposals invite spam. Limit deploys per SIWE address and sponsored actions per member per day.
  • Email. Sending mail needs an outside provider, so addresses leave the enclave at that one step. Encrypt the list to the enclave on upload, keep it only in memory, and drop it after sending.

UI (Genji)

PagePurpose
/Create a DAO: name, members (emails or number), parameters with sensible defaults
/created/:govShows addresses and, for the count option, the codes once, with CSV download and a warning that they won't be shown again
/claim/:gov#codeCreates or opens a passkey wallet, signs the claim, submits it
/join/:govCreates a passkey wallet for a user who joined later and hands its address back to the service for an admin mint
/dao/:govTreasury, members, manifesto, registry state, proposal list
/dao/:gov/proposeProposal builder with templates: send funds, set a state hash, update the manifesto, add a member
/dao/:gov/proposal/:idProposal detail, vote, execute

Proposal templates matter more than a generic calldata editor: most members will only ever send funds or approve a hash.

Treasury

The Governor holds whatever it receives: ETH or any ERC-20 token. For a service that budgets in fiat, a stablecoin such as EURC or USDC keeps the amount understandable to its accounting.

Fund after the claim window, or after a claim threshold. Quorum is a fraction of members at each proposal's snapshot. If the treasury is funded while only a few codes are claimed, those few members could pass a transfer before the rest arrive. The UI should show the claimed count and recommend funding once claims are well along.

Trust model

ActorCanCannot
ServiceChoose who receives codes; fund or not fundVote without a code; take funds back once sent
RelayerDelay or refuse to submit signaturesForge claims, votes or proposals; keep codes (in the TEE design)
Operator (ClaimOperator)Mint for committed codes until the claim deadline; relay admin mints within the capMint for anything else, or past its deadlines and cap
Service admin keyAdd up to adminCap members per period until expiryExceed the cap; survive a DAO vote revoking it; remove members
MembersPropose, vote, execute; add or remove members by voteTransfer or sell their vote

The service picks the first electorate. That is acceptable for this product, and the UI should say it plainly. After the deadline, membership changes only by vote.

A member who loses a passkey can be re-added by a governance vote: burn the old token with govBurn and mint to a new address with safeMint.

Default parameters

Suggested defaults, all changeable at deploy and later by vote:

ParameterDefaultReason
Voting delay1 dayTime to notice a proposal
Voting period7 daysFits non-crypto communities' pace
Proposal threshold1 voteAny member can propose
Quorum20%Reachable with partial participation
Claim window30 daysLong enough for email-based onboarding
Admin cap10 mints per 7 daysSteady growth; bounded damage if the key leaks
Admin expiry365 daysForces the DAO to renew the service's power once a year

Open questions

  • Should a DAO be able to reopen a claim window by vote (new root, new deadline), or should later members always come through safeMint?
  • Should the relayer sponsor gas indefinitely, or only during an initial period after which the DAO pays for its own relaying from the treasury?
  • Should the admin cap be a fixed number or a percentage of current supply, so it scales with the community?
  • Is per-member metadata (tokenURI) worth exposing, or should every membership share one URI?
  • Does the service need an off-chain integration (webhook or SDK) that reads StateRegistry and refuses to deploy a non-approved build?

Milestones

  1. Contracts: GovProposeBySig with tracking moved to _propose, ClaimOperator (claims and admin path), StateRegistry, DaoFactory, with Foundry tests including front-running, replay and admin-cap cases.
  2. Admin helper: the npm package and CLI, plus the /join handoff.
  3. Relayer: code generation, tree publishing, POST /daos and the sponsored routes in Wulong, with rate limits and gas policy.
  4. UI: create, claim and proposal pages in Genji.
  5. Pilot: one real service on Base Sepolia, then Base.

Further reading