diff --git a/.github/workflows/dao-space.yml b/.github/workflows/dao-space.yml
new file mode 100644
index 00000000..76421364
--- /dev/null
+++ b/.github/workflows/dao-space.yml
@@ -0,0 +1,30 @@
+name: Check the DAO pages against the Snapshot space
+
+on:
+ pull_request:
+ paths:
+ - 'docs/dao/**'
+ - 'scripts/check-dao-space*.js'
+ - 'scripts/fixtures/dao-space.json'
+ - '.github/workflows/dao-space.yml'
+ schedule:
+ # Once a week, Monday 06:00 UTC
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: Setup Node
+ uses: actions/setup-node@v7
+ with:
+ node-version: '^24.18.0'
+
+ - name: Test the settings checker
+ run: node --test scripts/check-dao-space.test.js
+
+ - name: Compare the pages with the space
+ run: node scripts/check-dao-space.js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2ffe03ae..11bfbceb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -39,6 +39,13 @@ node ./libs/link-validator.js http://localhost:8082 ./docs/.vitepress/dist/
Hosts that block automated requests are listed in [`libs/link-validator-ignore.json`](./libs/link-validator-ignore.json).
+The DAO pages describe the `api3.eth` space on Snapshot. [`scripts/fixtures/dao-space.json`](./scripts/fixtures/dao-space.json) records its documented configuration, including the complete voting power strategy parameters, privacy, eligibility and multisigs. [`scripts/check-dao-space.js`](./scripts/check-dao-space.js) compares the hub's settings with that configuration and checks the contracts, reads and settings named on the pages. Update the configuration and the pages together when an approved governance change takes effect, never just to make the check pass. The [workflow](./.github/workflows/dao-space.yml) tests the checker and runs the comparison on relevant pull requests and once a week. Reproduce it locally with:
+
+```sh
+pnpm test
+node scripts/check-dao-space.js
+```
+
## Issues and pull requests
Check the existing issues before opening a new one. Open pull requests against `main` and reference the issue in the description, for example `Closes #1`. CI runs the format check, the build and the link validator on every pull request, and the preview workflow posts a link to a Firebase preview of the site. Merging to `main` deploys the live site.
diff --git a/README.md b/README.md
index 98b4a80f..0e308172 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ New to API3 data feeds? Start with the [Quickstart](https://docs.api3.org/dapps/
- **[dApps](https://docs.api3.org/dapps/)**: activating a data feed on [API3 Market](https://market.api3.org), reading it from a contract through `Api3ReaderProxyV1`, integration and security considerations, and how OEV Rewards pay dApps for using the feeds.
- **[OEV](https://docs.api3.org/oev/)**: what Oracle Extractable Value is, how API3 data feeds and OEV feeds work at the contract level, and how searchers use the public Signed APIs.
- **[Curation](https://docs.api3.org/curation/)**: the Morpho vaults API3 curates, their roles and operations, risk management and disclosure.
+- **[DAO](https://docs.api3.org/dao/)**: governance on the `api3.eth` Snapshot space, what counts as voting power, delegation, voting, posting a proposal and how a result is verified before execution.
## AI assistants
diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js
index 309cd20a..756255cd 100644
--- a/docs/.vitepress/config.js
+++ b/docs/.vitepress/config.js
@@ -84,6 +84,7 @@ module.exports = {
'/dapps/': require('../dapps/sidebar.js'),
'/oev/': require('../oev/sidebar.js'),
'/curation/': require('../curation/sidebar.js'),
+ '/dao/': require('../dao/sidebar.js'),
},
nav: nav(),
},
@@ -107,5 +108,10 @@ function nav() {
link: '/curation/',
activeMatch: '/curation/.*',
},
+ {
+ text: 'DAO',
+ link: '/dao/',
+ activeMatch: '/dao/.*',
+ },
];
}
diff --git a/docs/dao/api3-token.md b/docs/dao/api3-token.md
new file mode 100644
index 00000000..75651b62
--- /dev/null
+++ b/docs/dao/api3-token.md
@@ -0,0 +1,49 @@
+---
+title: API3 token
+pageHeader: DAO
+outline: deep
+---
+
+
+
+# API3 token
+
+API3 is the governance token of the API3 DAO, an ERC-20 token on Ethereum mainnet.
+
+| Property | Value |
+| ------------ | --------------------------------------------------------------------------------------------------------------------- |
+| Contract | [`0x0b38210ea11411557c13457D4dA7dC6ea731B88a`](https://etherscan.io/token/0x0b38210ea11411557c13457D4dA7dC6ea731B88a) |
+| Symbol | API3 |
+| Decimals | 18 |
+| Total supply | 200,000,000 API3 |
+
+## Supply
+
+The contract minted 100,000,000 API3 when it was deployed.
+Minting is restricted to addresses its owner marks as minters.
+The DAO pool minted the staking rewards of the legacy DAO.
+
+The migration to Snapshot closed the supply.
+The pool's minter status was revoked, a final mint to the DAO's [Treasury multisig](/dao/#treasuries) brought the supply to 200,000,000 API3, and the contract's ownership was transferred to the dead address, so no address can mint and none can be made a minter.
+
+Any holder can burn their own API3 by opting in as a burner on the contract and calling `burn`.
+Nothing can mint it back, so the supply can only fall from here.
+
+## Governance
+
+Each API3 in your wallet, in the DAO pool, scheduled for unstaking or posted as collateral in the Kabu API3/USDC market on Morpho is one vote in the `api3.eth` space (see [Voting power](/dao/voting-power)).
+Exchange balances, liquidity pool tokens and tokens bridged to another chain do not count as your voting power.
+Other contracts can explicitly delegate their own API3 token balance on Snapshot (see [Voting power](/dao/voting-power#what-counts)).
+Posting a proposal takes 500,000 API3 of voting power (see [Posting a proposal](/dao/voting#posting-a-proposal)).
+
+## Staking
+
+The DAO pool of the legacy DAO holds staked API3.
+Staking rewards ended with the migration and the pool mints nothing further.
+Staking is no longer needed to vote, staked API3 keeps voting as described under [Voting power](/dao/voting-power#what-counts), and unstaking and withdrawing work as before.
+The [legacy documentation](https://dao-docs.api3.org/members/staking.html) describes the pool.
+
+## Collateral on Morpho
+
+The [Kabu API3/USDC market](https://app.morpho.org/ethereum/market/0x70dfbd2705d10d5dfb4d89e07e2bb860b5a624ce2f97e209ab9f5308bf6c67a1) on Morpho takes API3 as collateral for borrowing USDC.
+API3 posted there counts as voting power for the owner of the position, borrowed or not.
diff --git a/docs/dao/index.md b/docs/dao/index.md
new file mode 100644
index 00000000..53976d98
--- /dev/null
+++ b/docs/dao/index.md
@@ -0,0 +1,51 @@
+---
+title: Overview
+pageHeader: DAO
+outline: deep
+---
+
+
+
+# DAO
+
+The API3 DAO governs through the Snapshot space [api3.eth](https://snapshot.box/#/s:api3.eth), where holders of the [API3 token](/dao/api3-token) vote on proposals.
+Voting is off-chain: a vote is a signed message that Snapshot collects, and the voting power behind it is read from Ethereum mainnet at the block the proposal names.
+The API3 multisigs execute a proposal once it has passed and its result has been verified.
+
+::: info 💡 Tip
+
+For quick reference, you can copy-paste [`llms-full.txt`](https://docs.api3.org/llms-full.txt) to your choice of AI assistant.
+
+:::
+
+## How it works
+
+- [Voting power](/dao/voting-power) is counted per API3 in your wallet, in the DAO pool, during unstaking and as collateral in the Kabu API3/USDC market on Morpho, at the block a proposal names.
+ [Delegation](/dao/voting-power#delegation) hands it to another address, either for your stake alone, inside the pool, or for everything you hold, on Snapshot.
+- [Voting](/dao/voting) is a gasless signature on snapshot.box, For or Against, during the seven days a proposal is open.
+ Anyone with 500,000 API3 of voting power can [post a proposal](/dao/voting#posting-a-proposal), and it passes with at least 10,000,000 API3 in favour and more votes for than against.
+- [Independent verification](/dao/verification) recomputes a result from the chain before the API3 multisigs execute it.
+
+## Governing proposal
+
+The [DAO migration proposal](https://api3.eth.limo/#/history/primary-17), passed in the legacy DAO, sets out the governance rules, the mandate and the charter.
+These pages explain how to participate under those rules.
+
+## Treasuries
+
+The space lists three multisigs as its treasuries.
+They hold the DAO's assets and execute what passes (see [Execution](/dao/voting#execution)).
+
+| Multisig | Role |
+| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [Vault](https://etherscan.io/address/0x8e03609ed680B237C7b6f8020472CA0687308b24) | Holds the DAO's assets other than API3 and controls `api3.eth` and its Snapshot settings. Posts proposals and votes with the pool stake delegated to it. |
+| [Agent](https://etherscan.io/address/0xBBE0dE9757F93e3306aDBfeBE906AB285EDD13DA) | Runs day-to-day operations and holds only what they need. Its own voting power is delegated to a dead address. |
+| [Treasury](https://etherscan.io/address/0xb7fa2Cb302C5dC127e61F27b92beC411cbf0AE4A) | Holds the DAO's API3. Its voting power is delegated to a dead address, and it does not post proposals. |
+
+Changes to the Snapshot settings and other rules of governance follow the [rule-change execution period](/dao/voting#execution).
+
+## Legacy DAO
+
+Governance used to run on Aragon contracts, with a staking pool and a dashboard for it.
+The pool is still in use: stake in it counts as voting power and pool delegations still apply to the stake, but staking is no longer needed to vote.
+The [legacy documentation](https://dao-docs.api3.org/) covers the pool, the dashboard and the contracts.
diff --git a/docs/dao/sidebar.js b/docs/dao/sidebar.js
new file mode 100644
index 00000000..ef481edb
--- /dev/null
+++ b/docs/dao/sidebar.js
@@ -0,0 +1,22 @@
+module.exports = [
+ {
+ text: 'Overview',
+ link: '/dao/',
+ },
+ {
+ text: 'API3 token',
+ link: '/dao/api3-token',
+ },
+ {
+ text: 'Voting power',
+ link: '/dao/voting-power',
+ },
+ {
+ text: 'Voting',
+ link: '/dao/voting',
+ },
+ {
+ text: 'Independent verification',
+ link: '/dao/verification',
+ },
+];
diff --git a/docs/dao/verification.md b/docs/dao/verification.md
new file mode 100644
index 00000000..14622b50
--- /dev/null
+++ b/docs/dao/verification.md
@@ -0,0 +1,37 @@
+---
+title: Independent verification
+pageHeader: DAO
+outline: deep
+---
+
+
+
+# Independent verification
+
+The DAO relies on Snapshot to run its votes.
+The space settings, the strategies that compute voting power and the sequencer and hub that collect and tally the votes are Snapshot's, and the DAO trusts them to work as described.
+A vote's signature proves who signed it and what they signed, not when Snapshot received it: Snapshot cannot forge a vote, but it could omit a signed vote or accept one after the deadline with a backdated timestamp.
+Voters should check that their vote appears in the proposal's vote list while voting is open.
+
+The DAO verifies the rest.
+A passed vote moves nothing by itself: before executing a proposal, the [API3 multisigs](/dao/#treasuries) recompute its result from the chain with the [Snapshot voting verifier](https://github.com/api3dao/snapshot-voting-verifier), never taking a result off a website.
+The tool's source is public, anyone can run it on any proposal of the space, and its README says how.
+
+The DAO's rules also go further than Snapshot's settings can express, such as counting only the votes in favour toward the quorum and the window for the snapshot block.
+The verification enforces those too, so a proposal that Snapshot shows as passed can still fail a check and not be executed.
+
+## What it establishes
+
+Given a proposal, the tool reads every vote the Snapshot hub lists and establishes, independently of Snapshot's own computation, that:
+
+- Every listed vote was signed by the voter it is listed under, names one of the proposal's choices and has a signed timestamp within the voting window.
+- Every listed voter's power matches the proposal's recorded strategies at the proposal's block, delegations included, recomputed from an Ethereum archive node and the delegate registry rather than from Snapshot's scoring service (see [Voting power](/dao/voting-power)).
+- The tally follows from those powers.
+- The proposal met the posting rules: the proposer's 500,000 API3 at the proposal's block, a snapshot block within its window (see [Snapshot block](/dao/voting#snapshot-block)), and seven days of voting with no delay.
+
+It then judges the result on what it recomputed: at least 10,000,000 API3 in favour and more votes for than against (see [Passing](/dao/voting#passing)).
+The run ends by saying whether the result is verified, and lists every problem it found.
+A verified result is one whose votes and tally match the recomputation, so it can still be a rejected proposal, and only a verified and passed proposal is executed.
+
+Matching the recorded strategies does not by itself establish that a change to the strategies or the space settings was authorized, and the tool does not judge whether a proposal is a change of the rules of governance.
+The multisig signers check both from the proposal's content (see [Execution](/dao/voting#execution)).
diff --git a/docs/dao/voting-power.md b/docs/dao/voting-power.md
new file mode 100644
index 00000000..9567de77
--- /dev/null
+++ b/docs/dao/voting-power.md
@@ -0,0 +1,131 @@
+---
+title: Voting power
+pageHeader: DAO
+outline: deep
+---
+
+
+
+# Voting power
+
+Voting power is counted in API3: each API3 held in one of the places below is one vote.
+Staking is not needed to vote, and any amount votes.
+The 500,000 API3 threshold applies to posting a proposal, not to voting.
+Snapshot shows voting power under the symbol vpAPI3.
+
+## What counts
+
+At the block a proposal is measured at, your base voting power is the sum of:
+
+- API3 in your wallet.
+- API3 staked in the DAO pool, locked rewards included.
+ Stake you delegated inside the pool counts for your delegate instead, and stake delegated to you counts for you unless you delegated inside the pool yourself.
+- API3 deposited in the pool but not staked, including API3 whose unstaking has completed but was not withdrawn yet.
+- API3 scheduled for unstaking, during the waiting period.
+- API3 posted as collateral in the Kabu API3/USDC market on Morpho.
+
+API3 held for you by an exchange or custodian does not count as yours.
+Neither do liquidity pool tokens, lending receipts or tokens bridged to another chain.
+
+API3 held by other contracts counts for you only if the contract explicitly delegates its own API3 token balance to you on Snapshot.
+Owning a share of a contract does not give you voting power over its underlying assets.
+
+[Snapshot delegation](#snapshot-delegation) applies on top of this sum: a delegation gives all of it to the delegate.
+Power delegated to you on Snapshot is added separately and is not delegated onward.
+
+## When it is measured
+
+Each proposal names a block, chosen by its author when posting.
+Voting power is read at that block and does not change for the rest of the vote.
+Tokens bought, staked, moved or delegated after that block count from the next proposal on.
+The block is at most 7,200 blocks, about a day, before the proposal was posted (see [Snapshot block](/dao/voting#snapshot-block)).
+
+The vote dialog of a proposal on snapshot.box shows your voting power at its block.
+
+## How it is computed
+
+The space computes voting power with Snapshot's `with-delegation` strategy, which applies Snapshot delegation on top of five sources read on Ethereum mainnet.
+The strategy is set in the [space settings](https://snapshot.box/#/s:api3.eth/settings) on Snapshot.
+
+| Source | Contract | Read |
+| ------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
+| API3 in the wallet | API3 token `0x0b38210ea11411557c13457D4dA7dC6ea731B88a` | The token balance |
+| API3 staked | API3 pool `0x6dd655f10d4b9E242aE186D9050B68F725c76d76` | `userVotingPower`, the user's own shares plus the shares delegated to them in the pool, converted to API3 with `totalStake` over `totalShares` |
+| API3 deposited | API3 pool | `users`, the `unstaked` field |
+| API3 unstaking | API3 pool | `users`, the `unstakeAmount` field |
+| API3 as Morpho collateral | Morpho `0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb` | `position`, the `collateral` field, in the Kabu API3/USDC market `0x70dfbd2705d10d5dfb4d89e07e2bb860b5a624ce2f97e209ab9f5308bf6c67a1` |
+
+Reading `userVotingPower` rather than the user's own stake is what makes pool delegation apply: the pool reports a delegating user's shares under the delegate and gives the delegating user zero.
+
+## Delegation
+
+Delegation hands your voting power to another address, which votes with it.
+There are two kinds, and they behave differently.
+
+| | Pool delegation | Snapshot delegation |
+| -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------ |
+| Made in | The pool contract | The [Delegates](https://snapshot.box/#/s:api3.eth/delegates) page of the space |
+| What is delegated | Your stake in the pool | Your base voting power, stake delegated to you in the pool included |
+| Follows a delegate who delegates on Snapshot | Yes | No |
+| Changing it | A transaction, then a week before the next change | A transaction |
+
+### Pool delegation
+
+Existing pool delegations carry over for the stake only.
+If you delegated your stake in the pool, it keeps voting with your delegate.
+Your wallet balance, deposits, unstaking amount and collateral still vote with you.
+
+The pool refuses a delegation change within a week of the last one.
+After delegating, you wait a week before you can undelegate or change the delegate, and after undelegating, a week before you can delegate again.
+
+The legacy dashboard is retained for unstaking and withdrawing, and [its documentation](https://dao-docs.api3.org/members/voting.html) describes how pool delegation worked there.
+To inspect or change a pool delegation now, open the [pool contract on Etherscan](https://etherscan.io/address/0x6dd655f10d4b9E242aE186D9050B68F725c76d76#code):
+
+1. Under **Read Contract**, call `userDelegate` with your wallet address to see its current delegate.
+ The zero address means no pool delegation.
+2. Under **Write Contract**, connect that wallet and call `undelegateVotingPower` to clear the delegation, or `delegateVotingPower` with the new delegate's address to change it.
+ These are transactions and cost gas.
+
+The one-week cooldown applies to either change.
+The pool refuses a new delegate who is already delegating inside the pool.
+
+### Snapshot delegation
+
+Snapshot delegation gives your delegate your base voting power, stake that others delegated to you in the pool included.
+You can still vote with power delegated to you on Snapshot, because Snapshot delegation does not chain.
+If you have no incoming Snapshot delegations, your voting power is zero.
+It is recorded in Snapshot's delegate registry on Ethereum mainnet, so setting, changing or clearing it is a transaction.
+
+A delegation can be set under `api3.eth` or under all spaces.
+A delegation under `api3.eth` overrides one under all spaces.
+
+To delegate, open the [Delegates](https://snapshot.box/#/s:api3.eth/delegates) page of the space, connect your wallet, choose the delegate and sign the transaction.
+
+### Details that matter
+
+- Snapshot delegation does not chain.
+ If you delegate to someone who has delegated to a third person, your power stays with your delegate, who votes with it.
+ Only their own holdings go on to the third person.
+- Pool delegation does pass through.
+ If you delegated your stake in the pool to someone who delegates on Snapshot, your stake follows them to their delegate.
+- A pool delegation to someone who has since delegated inside the pool counts for nobody.
+ The pool gives a delegating user no voting power at all, received delegations included, and does not pass them on.
+ Re-delegate or undelegate in the pool to fix it.
+- Voting yourself after delegating does not take your base voting power back from your delegate.
+ You can still use any incoming Snapshot delegations.
+ To vote with your own holdings again, clear the applicable space and all-spaces delegations before the proposal's block.
+- Changing or clearing a delegation counts from the next proposal's block.
+ A proposal already posted keeps the delegations as they were at its block.
+- To put your own holdings with one delegate, clear any outgoing pool delegation first, then delegate on Snapshot.
+ While a pool delegation stands, that stake follows the pool delegate's Snapshot delegation, not yours.
+ Incoming Snapshot delegations stay with you and cannot be passed on.
+
+## Other cases
+
+- API3 held for you by an exchange or a custodian is in their wallet, not yours, and does not vote as yours.
+- A Safe can vote directly through the Safe app (see [Voting](/dao/voting)).
+ Other contract wallets need a compatible signing method or must explicitly delegate their own API3 balance.
+- Collateral on Morpho counts for the owner of the position, in the Kabu API3/USDC market only.
+ Borrowing against it changes nothing.
+- The Vault votes with the pool stake delegated to it.
+ The Agent and Treasury delegate their own voting power to a dead address and do not vote (see [Treasuries](/dao/#treasuries)).
diff --git a/docs/dao/voting.md b/docs/dao/voting.md
new file mode 100644
index 00000000..28023b83
--- /dev/null
+++ b/docs/dao/voting.md
@@ -0,0 +1,99 @@
+---
+title: Voting
+pageHeader: DAO
+outline: deep
+---
+
+
+
+# Voting
+
+Proposals of the API3 DAO are listed in the [api3.eth](https://snapshot.box/#/s:api3.eth) space on snapshot.box.
+Voting opens when Snapshot's sequencer accepts a proposal, with no delay, and closes seven days later.
+
+## Casting a vote
+
+1. Open the proposal on snapshot.box and connect the wallet that holds the voting power.
+2. Choose For or Against.
+3. Sign the vote.
+ It is a signature, not a transaction, and costs no gas.
+
+Your vote appears in the proposal's vote list with the voting power it carries.
+Votes are public while the proposal is open.
+
+## What to know
+
+- Your voting power is read at the block the proposal names, so tokens or delegations changed after that block do not count (see [Voting power](/dao/voting-power#when-it-is-measured)).
+- Any amount of voting power votes.
+ The 500,000 API3 threshold applies to posting a proposal, not to voting.
+- If you delegated on Snapshot, your base voting power goes to your delegate.
+ You can still vote with incoming Snapshot delegations.
+ Clear your delegation before the proposal's block to vote with your own holdings (see [Delegation](/dao/voting-power#delegation)).
+- Abstain is not on the ballot.
+ The ballot shows For and Against, the abstain option is hidden, and an abstain cast through the Snapshot API counts for nothing.
+- To vote from a Safe, open Snapshot inside the Safe app and collect the required confirmations.
+ Confirming a Safe message alone does not guarantee that it was delivered to Snapshot.
+ Check that the vote appears in the proposal's vote list before voting closes.
+
+## Posting a proposal
+
+Posting a proposal takes 500,000 API3 of voting power, held or delegated to you.
+Snapshot checks it at the latest block when it receives the proposal, and the [independent verification](/dao/verification) checks it again at the proposal's snapshot block.
+
+Proposals are discussed on the [API3 Discord](https://discord.gg/api3dao).
+
+1. Open [New proposal](https://snapshot.box/#/s:api3.eth/create) in the space and connect the wallet that holds the voting power.
+2. Write the title and the body.
+ The voting system is basic voting with the choices For and Against.
+3. Leave the snapshot block at its default, the latest block (see [Snapshot block](#snapshot-block)).
+4. Publish by signing.
+ Voting starts when the sequencer accepts the proposal.
+
+To post from a Safe, open snapshot.box in the Safe app, collect the required confirmations within a day of the first signature and deliver the signed proposal to Snapshot.
+Check that the proposal appears in the space.
+The author can delete their own proposal.
+
+### Snapshot block
+
+The author picks the block when posting, and Snapshot only checks that the block exists.
+The DAO's rule is that the block is at most 7,200 blocks, about a day, before Snapshot received the proposal.
+A proposal outside this window is not verified and is not executed.
+
+Snapshot sets the proposal's creation time when its sequencer accepts it and moves a signed start time in the past forward, so voting always runs the full seven days from acceptance.
+The snapshot block is fixed when the proposal is prepared, so a Safe has to collect its confirmations and deliver the proposal while the block is still within the window.
+
+## Passing
+
+A proposal passes when at least 10,000,000 API3 was cast in favour and there are more votes for than against.
+
+Snapshot's quorum bar counts every vote cast, against included, while the DAO's rule counts the votes in favour only.
+The bar on snapshot.box can therefore show the quorum as met before the rule is.
+The [independent verification](/dao/verification) applies the rule.
+
+## Execution
+
+Within seven days of passing, the [API3 multisigs](/dao/#treasuries) verify the result and execute the proposal.
+
+A proposal that changes the rules of governance executes 30 days after passing, and a proposal that cancels such a change executes within seven days.
+The rules of governance are the voting rules, the DAO's [mandate and charter](/dao/#governing-proposal), the voting power strategy, the space settings, the verification tool, the `api3.eth` name and which multisig holds the treasury.
+Whether a proposal is a rule change follows from its content, not from anything the author declares.
+
+## Space settings
+
+The settings of the `api3.eth` space that carry these rules, as set on Snapshot:
+
+| Setting | Value |
+| ------------------ | ---------------------------- |
+| Network | Ethereum mainnet |
+| Voting type | Basic |
+| Proposal threshold | 500,000 API3 |
+| Voting delay | None |
+| Voting period | 7 days |
+| Quorum | 10,000,000 API3 in favour |
+| Abstain | Hidden |
+| Vote privacy | Public |
+| Who can vote | Any voting power holder |
+| Posting access | Anyone meeting the threshold |
+
+Snapshot enforces the threshold, the voting type and the timing.
+The quorum in favour, the majority and the 30 days of a rule change are checked by the API3 multisigs before executing, with the [independent verification](/dao/verification).
diff --git a/docs/index.md b/docs/index.md
index 267113b2..8b0b9f6b 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,4 +19,7 @@ hero:
- theme: brand
text: About curation
link: /curation/
+ - theme: brand
+ text: About the DAO
+ link: /dao/
---
diff --git a/package.json b/package.json
index 9e171bab..5663d918 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"format": "prettier --write --cache \"./**/*.{js,vue,md,json,yaml}\" --log-level silent",
"format:check": "prettier --check --cache \"./**/*.{js,vue,md,json,yaml}\"",
"lint": "pnpm format:check",
+ "test": "node --test scripts/*.test.js",
"generate-llms-files": "node scripts/generate-llms-files.js",
"prepare": "husky",
"firebase:emulator": "pnpm docs:build && firebase emulators:start"
diff --git a/scripts/check-dao-space.js b/scripts/check-dao-space.js
new file mode 100644
index 00000000..11d0b338
--- /dev/null
+++ b/scripts/check-dao-space.js
@@ -0,0 +1,194 @@
+// Checks the DAO pages against the live api3.eth space on the Snapshot hub.
+// Pin the complete strategy parameters and participation rules as well as
+// checking the contracts, reads and settings named on the pages.
+const fs = require('fs');
+const path = require('path');
+const { isDeepStrictEqual } = require('node:util');
+const documentedSpace = require('./fixtures/dao-space.json');
+
+const HUB = 'https://hub.snapshot.org/graphql';
+const SPACE = 'api3.eth';
+const daoDir = path.join(__dirname, '..', 'docs', 'dao');
+
+const query = `{
+ space(id: "${SPACE}") {
+ network
+ symbol
+ strategies { name network params }
+ voting { type delay period quorum hideAbstain privacy }
+ validation { name params }
+ voteValidation { name params }
+ filters { onlyMembers }
+ admins
+ members
+ moderators
+ treasuries { name address network }
+ }
+}`;
+
+async function fetchSpace() {
+ const response = await fetch(HUB, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ query }),
+ signal: AbortSignal.timeout(30000),
+ });
+ if (!response.ok) {
+ throw new Error(`The hub returned ${response.status}`);
+ }
+ const { data, errors } = await response.json();
+ if (errors) {
+ throw new Error(`The hub returned errors: ${JSON.stringify(errors)}`);
+ }
+ if (!data.space) {
+ throw new Error(`The hub has no space ${SPACE}`);
+ }
+ return data.space;
+}
+
+// Every value a strategy reads: contract addresses, bytes32 arguments such as
+// a market id, method names and output fields.
+function collectReads(value, reads = new Set()) {
+ if (Array.isArray(value)) {
+ value.forEach((item) => collectReads(item, reads));
+ } else if (value && typeof value === 'object') {
+ for (const [key, item] of Object.entries(value)) {
+ if (key === 'address' || key === 'output') {
+ reads.add(item);
+ } else if (key === 'methodABI') {
+ reads.add(item.name);
+ } else if (key === 'args') {
+ item.filter((arg) => /^0x[0-9a-fA-F]{64}$/.test(arg)).forEach((arg) => reads.add(arg));
+ } else {
+ collectReads(item, reads);
+ }
+ }
+ }
+ return reads;
+}
+
+function formatAmount(amount) {
+ return `${Number(amount).toLocaleString('en-US')} API3`;
+}
+
+function formatDays(seconds) {
+ const days = seconds / 86400;
+ return `${Number.isInteger(days) ? days : days.toFixed(2)} days`;
+}
+
+function escapeRegExp(text) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function hasRow(page, setting, value) {
+ const row = new RegExp(`^\\|\\s*${escapeRegExp(setting)}\\s*\\|\\s*${escapeRegExp(value)}\\s*\\|\\s*$`, 'm');
+ return row.test(page);
+}
+
+function checkSpace(space, pages) {
+ const problems = [];
+ const { votingPowerPage, votingPage, tokenPage, overviewPage } = pages;
+
+ for (const [setting, expected] of Object.entries(documentedSpace)) {
+ if (!isDeepStrictEqual(space[setting], expected)) {
+ problems.push(`${setting} differs from the documented configuration in scripts/fixtures/dao-space.json`);
+ }
+ }
+
+ for (const { name, address } of documentedSpace.treasuries) {
+ if (!overviewPage.includes(`[${name}](https://etherscan.io/address/${address})`)) {
+ problems.push(`index.md does not link ${name} to its documented multisig ${address}`);
+ }
+ }
+
+ if (space.network !== '1') {
+ problems.push(`The space is on network ${space.network}, the pages say Ethereum mainnet`);
+ }
+ const strategies = space.strategies ?? [];
+ if (strategies.length !== 1 || strategies[0].name !== 'with-delegation') {
+ problems.push(
+ `The space runs ${strategies.map((strategy) => strategy.name).join(', ')}, the voting power page describes one with-delegation strategy`
+ );
+ }
+ const sources = strategies[0]?.params?.strategies ?? [];
+ if (sources.length !== 5) {
+ problems.push(`The strategy reads ${sources.length} sources, the voting power page describes five`);
+ }
+
+ const reads = collectReads(strategies);
+ for (const read of reads) {
+ if (!votingPowerPage.includes(read)) {
+ problems.push(`voting-power.md does not mention ${read}, which the space's strategies read`);
+ }
+ }
+ for (const hex of new Set(votingPowerPage.match(/0x[0-9a-fA-F]{40,}/g) ?? [])) {
+ if (!reads.has(hex)) {
+ problems.push(`voting-power.md lists ${hex}, which the space's strategies do not read`);
+ }
+ }
+
+ const tokenAddress = sources.find((source) => source.name === 'erc20-balance-of')?.params?.address;
+ if (!tokenAddress) {
+ problems.push('The strategy has no erc20-balance-of source, the token page describes the token it reads');
+ } else if (!tokenPage.includes(tokenAddress)) {
+ problems.push(`api3-token.md does not name the token ${tokenAddress} the space reads`);
+ }
+ for (const hex of new Set(tokenPage.match(/0x[0-9a-fA-F]{40,}/g) ?? [])) {
+ if (!reads.has(hex)) {
+ problems.push(`api3-token.md lists ${hex}, which the space's strategies do not read`);
+ }
+ }
+
+ if (space.validation?.name !== 'basic') {
+ problems.push(
+ `The space validates proposals with ${space.validation?.name}, the voting page describes a basic threshold`
+ );
+ }
+ const voting = space.voting ?? {};
+ const rows = {
+ 'Voting type': voting.type === 'basic' ? 'Basic' : String(voting.type),
+ 'Proposal threshold': formatAmount(space.validation?.params?.minScore),
+ 'Voting delay': voting.delay ? formatDays(voting.delay) : 'None',
+ 'Voting period': formatDays(voting.period),
+ Quorum: `${formatAmount(voting.quorum)} in favour`,
+ Abstain: voting.hideAbstain === true ? 'Hidden' : 'Visible',
+ 'Vote privacy': voting.privacy === '' ? 'Public' : String(voting.privacy),
+ 'Who can vote':
+ space.voteValidation?.name === 'any' ? 'Any voting power holder' : String(space.voteValidation?.name),
+ 'Posting access': space.filters?.onlyMembers === false ? 'Anyone meeting the threshold' : 'Members only',
+ };
+ for (const [setting, value] of Object.entries(rows)) {
+ if (!hasRow(votingPage, setting, value)) {
+ problems.push(`voting.md has no settings row "${setting}" with the value "${value}" the space is set to`);
+ }
+ }
+
+ return problems;
+}
+
+async function main() {
+ const space = await fetchSpace();
+ const readPage = (file) => fs.readFileSync(path.join(daoDir, file), 'utf-8');
+ const problems = checkSpace(space, {
+ votingPowerPage: readPage('voting-power.md'),
+ votingPage: readPage('voting.md'),
+ tokenPage: readPage('api3-token.md'),
+ overviewPage: readPage('index.md'),
+ });
+
+ if (problems.length > 0) {
+ console.error(`The DAO pages differ from the ${SPACE} space on the hub:`);
+ problems.forEach((problem) => console.error(`- ${problem}`));
+ process.exit(1);
+ }
+ console.log(`The DAO pages match the ${SPACE} space on the hub.`);
+}
+
+module.exports = { checkSpace };
+
+if (require.main === module) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exit(1);
+ });
+}
diff --git a/scripts/check-dao-space.test.js b/scripts/check-dao-space.test.js
new file mode 100644
index 00000000..8e7062c1
--- /dev/null
+++ b/scripts/check-dao-space.test.js
@@ -0,0 +1,222 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { test } = require('node:test');
+const { checkSpace } = require('./check-dao-space');
+const documentedSpace = require('./fixtures/dao-space.json');
+
+const readPage = (name) => fs.readFileSync(path.join(__dirname, '..', 'docs', 'dao', name), 'utf8');
+const pages = {
+ votingPowerPage: readPage('voting-power.md'),
+ votingPage: readPage('voting.md'),
+ tokenPage: readPage('api3-token.md'),
+ overviewPage: readPage('index.md'),
+};
+
+test('the documented configuration matches the DAO pages', () => {
+ assert.deepEqual(checkSpace(documentedSpace, pages), []);
+});
+
+const changes = [
+ [
+ 'stake multiplication becomes division',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies[1].params.operands[0].strategy.params.operation = 'divide';
+ },
+ ],
+ [
+ 'stake division uses reversed operands',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies[1].params.operands.reverse();
+ },
+ ],
+ [
+ 'token decimals change',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies[0].params.decimals = 6;
+ },
+ ],
+ [
+ 'a strategy reads another network',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies[0].network = '137';
+ },
+ ],
+ [
+ 'delegation reads another space',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.delegationSpace = 'another.eth';
+ },
+ ],
+ [
+ 'delegation reads another network',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.delegationNetwork = '137';
+ },
+ ],
+ [
+ 'the Morpho market changes',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies[4].params.args[0] = `0x${'1'.repeat(64)}`;
+ },
+ ],
+ [
+ 'a source is removed',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies.pop();
+ },
+ ],
+ [
+ 'a source is added',
+ 'strategies',
+ (space) => {
+ space.strategies[0].params.strategies.push(structuredClone(space.strategies[0].params.strategies[0]));
+ },
+ ],
+ [
+ 'abstain becomes visible',
+ 'voting',
+ (space) => {
+ space.voting.hideAbstain = false;
+ },
+ ],
+ [
+ 'authors can choose vote privacy',
+ 'voting',
+ (space) => {
+ space.voting.privacy = 'any';
+ },
+ ],
+ [
+ 'votes become private',
+ 'voting',
+ (space) => {
+ space.voting.privacy = 'shutter';
+ },
+ ],
+ [
+ 'voting is restricted',
+ 'voteValidation',
+ (space) => {
+ space.voteValidation = { name: 'whitelist', params: { addresses: [] } };
+ },
+ ],
+ [
+ 'only members can post',
+ 'filters',
+ (space) => {
+ space.filters.onlyMembers = true;
+ },
+ ],
+ [
+ 'proposal validation uses different strategies',
+ 'validation',
+ (space) => {
+ space.validation.params.strategies = [];
+ },
+ ],
+ [
+ 'the posting threshold changes',
+ 'validation',
+ (space) => {
+ space.validation.params.minScore = 1;
+ },
+ ],
+ [
+ 'the voting type is not fixed',
+ 'voting',
+ (space) => {
+ space.voting.type = null;
+ },
+ ],
+ [
+ 'the voting period changes',
+ 'voting',
+ (space) => {
+ space.voting.period = 86400;
+ },
+ ],
+ [
+ 'the quorum changes',
+ 'voting',
+ (space) => {
+ space.voting.quorum = 0;
+ },
+ ],
+ [
+ 'the voting delay changes',
+ 'voting',
+ (space) => {
+ space.voting.delay = 3600;
+ },
+ ],
+ [
+ 'the Treasury address changes',
+ 'treasuries',
+ (space) => {
+ space.treasuries[2].address = space.treasuries[0].address;
+ },
+ ],
+];
+
+for (const role of ['admins', 'members', 'moderators']) {
+ changes.push([
+ `${role} gain access`,
+ role,
+ (space) => {
+ space[role].push(space.treasuries[0].address);
+ },
+ ]);
+}
+
+for (const [name, setting, mutate] of changes) {
+ test(`rejects when ${name}`, () => {
+ const space = structuredClone(documentedSpace);
+ mutate(space);
+ const problems = checkSpace(space, pages);
+ assert.ok(
+ problems.some((problem) => problem.startsWith(`${setting} differs`)),
+ problems.join('\n')
+ );
+ });
+}
+
+test('rejects a stale threshold in the voting page even when the settings match', () => {
+ const votingPage = pages.votingPage.replace(/500,000 API3/g, '50,000 API3');
+ assert.ok(
+ checkSpace(documentedSpace, { ...pages, votingPage }).some((problem) => problem.includes('Proposal threshold'))
+ );
+});
+
+test('rejects a stale vote privacy description even when the settings match', () => {
+ const votingPage = pages.votingPage.replace('Public', 'Private');
+ assert.ok(checkSpace(documentedSpace, { ...pages, votingPage }).some((problem) => problem.includes('Vote privacy')));
+});
+
+test('rejects a missing pool read in the voting power page', () => {
+ const votingPowerPage = pages.votingPowerPage.replaceAll('userVotingPower', 'userStake');
+ assert.ok(
+ checkSpace(documentedSpace, { ...pages, votingPowerPage }).some((problem) => problem.includes('userVotingPower'))
+ );
+});
+
+test('rejects a different token contract in the token page', () => {
+ const tokenPage = pages.tokenPage.replaceAll(
+ documentedSpace.strategies[0].params.strategies[0].params.address,
+ `0x${'1'.repeat(40)}`
+ );
+ assert.ok(checkSpace(documentedSpace, { ...pages, tokenPage }).some((problem) => problem.includes('api3-token.md')));
+});
+
+test('rejects an incorrect Treasury link in the overview', () => {
+ const overviewPage = pages.overviewPage.replace(documentedSpace.treasuries[2].address, `0x${'1'.repeat(40)}`);
+ assert.ok(checkSpace(documentedSpace, { ...pages, overviewPage }).some((problem) => problem.includes('index.md')));
+});
diff --git a/scripts/fixtures/dao-space.json b/scripts/fixtures/dao-space.json
new file mode 100644
index 00000000..4ecf7983
--- /dev/null
+++ b/scripts/fixtures/dao-space.json
@@ -0,0 +1,306 @@
+{
+ "network": "1",
+ "symbol": "vpAPI3",
+ "strategies": [
+ {
+ "name": "with-delegation",
+ "network": "1",
+ "params": {
+ "symbol": "API3",
+ "strategies": [
+ {
+ "name": "erc20-balance-of",
+ "params": {
+ "symbol": "API3",
+ "address": "0x0b38210ea11411557c13457D4dA7dC6ea731B88a",
+ "decimals": 18
+ }
+ },
+ {
+ "name": "math",
+ "params": {
+ "symbol": "API3 (staked)",
+ "operation": "divide",
+ "operands": [
+ {
+ "type": "strategy",
+ "strategy": {
+ "name": "math",
+ "params": {
+ "symbol": "API3 (staked, times total stake)",
+ "operation": "multiply",
+ "operands": [
+ {
+ "type": "strategy",
+ "strategy": {
+ "name": "contract-call",
+ "params": {
+ "symbol": "Pool shares (own plus delegated)",
+ "address": "0x6dd655f10d4b9E242aE186D9050B68F725c76d76",
+ "decimals": 18,
+ "args": ["%{address}"],
+ "methodABI": {
+ "name": "userVotingPower",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [
+ {
+ "name": "userAddress",
+ "type": "address"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "",
+ "type": "uint256"
+ }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "type": "strategy",
+ "strategy": {
+ "name": "contract-call",
+ "params": {
+ "symbol": "Pool total stake",
+ "address": "0x6dd655f10d4b9E242aE186D9050B68F725c76d76",
+ "decimals": 18,
+ "args": [],
+ "methodABI": {
+ "name": "totalStake",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "",
+ "type": "uint256"
+ }
+ ]
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ },
+ {
+ "type": "strategy",
+ "strategy": {
+ "name": "contract-call",
+ "params": {
+ "symbol": "Pool total shares",
+ "address": "0x6dd655f10d4b9E242aE186D9050B68F725c76d76",
+ "decimals": 18,
+ "args": [],
+ "methodABI": {
+ "name": "totalShares",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [],
+ "outputs": [
+ {
+ "name": "",
+ "type": "uint256"
+ }
+ ]
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ {
+ "name": "contract-call",
+ "params": {
+ "symbol": "API3 (deposited)",
+ "address": "0x6dd655f10d4b9E242aE186D9050B68F725c76d76",
+ "decimals": 18,
+ "args": ["%{address}"],
+ "output": "unstaked",
+ "methodABI": {
+ "name": "users",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [
+ {
+ "name": "userAddress",
+ "type": "address"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "unstaked",
+ "type": "uint256"
+ },
+ {
+ "name": "vesting",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeAmount",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeShares",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeScheduledFor",
+ "type": "uint256"
+ },
+ {
+ "name": "lastDelegationUpdateTimestamp",
+ "type": "uint256"
+ },
+ {
+ "name": "lastProposalTimestamp",
+ "type": "uint256"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "name": "contract-call",
+ "params": {
+ "symbol": "API3 (unstaking)",
+ "address": "0x6dd655f10d4b9E242aE186D9050B68F725c76d76",
+ "decimals": 18,
+ "args": ["%{address}"],
+ "output": "unstakeAmount",
+ "methodABI": {
+ "name": "users",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [
+ {
+ "name": "userAddress",
+ "type": "address"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "unstaked",
+ "type": "uint256"
+ },
+ {
+ "name": "vesting",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeAmount",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeShares",
+ "type": "uint256"
+ },
+ {
+ "name": "unstakeScheduledFor",
+ "type": "uint256"
+ },
+ {
+ "name": "lastDelegationUpdateTimestamp",
+ "type": "uint256"
+ },
+ {
+ "name": "lastProposalTimestamp",
+ "type": "uint256"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "name": "contract-call",
+ "params": {
+ "symbol": "API3 (Morpho collateral)",
+ "address": "0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb",
+ "decimals": 18,
+ "args": [
+ "0x70dfbd2705d10d5dfb4d89e07e2bb860b5a624ce2f97e209ab9f5308bf6c67a1",
+ "%{address}"
+ ],
+ "output": "collateral",
+ "methodABI": {
+ "name": "position",
+ "type": "function",
+ "stateMutability": "view",
+ "inputs": [
+ {
+ "name": "id",
+ "type": "bytes32"
+ },
+ {
+ "name": "user",
+ "type": "address"
+ }
+ ],
+ "outputs": [
+ {
+ "name": "supplyShares",
+ "type": "uint256"
+ },
+ {
+ "name": "borrowShares",
+ "type": "uint128"
+ },
+ {
+ "name": "collateral",
+ "type": "uint128"
+ }
+ ]
+ }
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "voting": {
+ "type": "basic",
+ "delay": null,
+ "period": 604800,
+ "quorum": 10000000,
+ "hideAbstain": true,
+ "privacy": ""
+ },
+ "validation": {
+ "name": "basic",
+ "params": {
+ "minScore": 500000
+ }
+ },
+ "voteValidation": {
+ "name": "any",
+ "params": {}
+ },
+ "filters": {
+ "onlyMembers": false
+ },
+ "admins": [],
+ "members": [],
+ "moderators": [],
+ "treasuries": [
+ {
+ "name": "Vault",
+ "address": "0x8e03609ed680B237C7b6f8020472CA0687308b24",
+ "network": "1"
+ },
+ {
+ "name": "Agent",
+ "address": "0xBBE0dE9757F93e3306aDBfeBE906AB285EDD13DA",
+ "network": "1"
+ },
+ {
+ "name": "Treasury",
+ "address": "0xb7fa2Cb302C5dC127e61F27b92beC411cbf0AE4A",
+ "network": "1"
+ }
+ ]
+}