diff --git a/.github/workflows/dao-space.yml b/.github/workflows/dao-space.yml index 854b639e..76421364 100644 --- a/.github/workflows/dao-space.yml +++ b/.github/workflows/dao-space.yml @@ -4,7 +4,8 @@ on: pull_request: paths: - 'docs/dao/**' - - 'scripts/check-dao-space.js' + - 'scripts/check-dao-space*.js' + - 'scripts/fixtures/dao-space.json' - '.github/workflows/dao-space.yml' schedule: # Once a week, Monday 06:00 UTC @@ -22,5 +23,8 @@ jobs: 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 e9a380c7..11bfbceb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,9 +39,10 @@ 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 repeat what the `api3.eth` space on Snapshot is set to: the contracts, methods and market its voting power strategy reads, and the proposal threshold, voting type, delay, period and quorum. [`scripts/check-dao-space.js`](./scripts/check-dao-space.js) reads the space from the Snapshot hub and fails when the pages differ from it. The [workflow](./.github/workflows/dao-space.yml) runs it on pull requests that touch `docs/dao` and once a week, so a settings change on Snapshot shows up as a failing run. Reproduce it locally with: +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 ``` diff --git a/docs/dao/api3-token.md b/docs/dao/api3-token.md index 92cec5c9..ca20088e 100644 --- a/docs/dao/api3-token.md +++ b/docs/dao/api3-token.md @@ -20,7 +20,8 @@ API3 is the governance token of the API3 DAO, an ERC-20 token on Ethereum mainne ## Supply The contract minted 100,000,000 API3 when it was deployed. -Minting is restricted to addresses its owner marks as minters, and the only minter it ever had was the DAO pool, which minted the staking rewards of the legacy DAO. +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 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. @@ -31,7 +32,8 @@ 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)). -API3 anywhere else, on an exchange, in a liquidity pool or bridged to another chain, does not vote. +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 diff --git a/docs/dao/index.md b/docs/dao/index.md index 9d6fe3d5..0542cf9c 100644 --- a/docs/dao/index.md +++ b/docs/dao/index.md @@ -25,6 +25,22 @@ For quick reference, you can copy-paste [`llms-full.txt`](https://docs.api3.org/ - [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. + See [Snapshot assumptions](/dao/snapshot-assumptions) for the remaining trust in vote collection and the space's settings. + +## Governing proposal + +The primary DAO migration proposal in the [legacy DAO dashboard](https://api3.eth.limo/#/history) sets out the governance rules, mandate and charter. +These pages explain how to participate under those rules. + +## DAO multisigs + +| Multisig | Role | +| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| [Vault](https://etherscan.io/address/0x8e03609ed680B237C7b6f8020472CA0687308b24) | Controls `api3.eth` and its Snapshot settings. Posts proposals and votes with the pool stake delegated to it. | +| [Agent](https://etherscan.io/address/0xBBE0dE9757F93e3306aDBfeBE906AB285EDD13DA) | Executes operational proposals. Its own voting power is delegated to a dead address. | +| [Treasury](https://etherscan.io/address/0xb7fa2Cb302C5dC127e61F27b92beC411cbf0AE4A) | Holds the DAO's treasury API3. Its own 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 diff --git a/docs/dao/sidebar.js b/docs/dao/sidebar.js index ef481edb..2105c802 100644 --- a/docs/dao/sidebar.js +++ b/docs/dao/sidebar.js @@ -19,4 +19,8 @@ module.exports = [ text: 'Independent verification', link: '/dao/verification', }, + { + text: 'Snapshot assumptions', + link: '/dao/snapshot-assumptions', + }, ]; diff --git a/docs/dao/snapshot-assumptions.md b/docs/dao/snapshot-assumptions.md new file mode 100644 index 00000000..f87b6a8b --- /dev/null +++ b/docs/dao/snapshot-assumptions.md @@ -0,0 +1,52 @@ +--- +title: Snapshot assumptions +pageHeader: DAO +outline: deep +--- + + + +# Snapshot assumptions + +Snapshot voting is off-chain. +The [independent verification](/dao/verification) recomputes voting power and the tally, while vote collection still relies on Snapshot. + +## Vote collection + +The Snapshot sequencer receives signed votes and decides whether they arrived before the deadline. +The hub supplies the list of accepted votes. +A signature proves who signed a vote and what they signed, including its timestamp, but does not independently prove when Snapshot received it. + +Snapshot cannot forge a voter's signature. +It could omit a signed vote or accept one after the deadline with a backdated signed timestamp. +Recomputing the final tally alone cannot rule those cases out. +Voters should check that their vote appears in the proposal's vote list. + +The verification tool archives the vote list on each run and compares it with the previous archive. +Running it at least daily while voting is open, and again at the close, helps detect votes that were inserted, removed or changed. +Those archives improve visibility into changes, but do not remove the trust in Snapshot's receipt and inclusion of votes. + +## Proposal timing + +Snapshot sets a proposal's creation time when the sequencer accepts it. +If its signed start time is in the past, the sequencer moves the start forward and keeps the full configured voting period. +For `api3.eth`, voting starts without a delay and runs for seven days. +Time spent collecting Safe confirmations does not shorten that period. + +The snapshot block is still the block chosen when preparing the proposal. +It can become too old while confirmations are collected, so a proposal must also meet the DAO's [snapshot block window](/dao/voting#snapshot-block). + +## Voting power and settings + +The verifier reads balances and contract state from an Ethereum archive RPC at the proposal's snapshot block, and delegations from the delegate registry's events. +It does not use Snapshot's score API or delegation subgraph for that recomputation. +The RPC must serve the correct Ethereum history. + +The recomputation uses the strategies recorded on the proposal. +Agreement with those strategies does not itself establish that a strategy or settings change was authorized. +The API3 multisigs also check that the proposal uses the DAO's approved governance rules before executing it. + +## Execution + +A passed Snapshot vote does not automatically move funds or change contracts. +The [DAO multisigs](/dao/#dao-multisigs) execute the verified result under the [governing proposal](/dao/#governing-proposal), including the execution period for a change to the rules of governance. diff --git a/docs/dao/verification.md b/docs/dao/verification.md index f02a8de1..17d1915f 100644 --- a/docs/dao/verification.md +++ b/docs/dao/verification.md @@ -8,10 +8,9 @@ outline: deep # Independent verification -The DAO relies on Snapshot to run its votes. -The space settings, the strategies that compute voting power and the hub that collects and tallies the votes are Snapshot's, and the DAO trusts them to work as described. -It verifies them all the same: before executing a proposal, the API3 multisigs 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 is open source, anyone can run it on any proposal of the space, and its README says how. +Before executing a proposal, the API3 multisigs 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 remaining trust in Snapshot's vote collection and settings is described under [Snapshot assumptions](/dao/snapshot-assumptions). 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. @@ -20,13 +19,15 @@ The verification enforces those too, so a proposal that Snapshot shows as passed Given a proposal, the tool reads every vote the Snapshot hub lists and establishes, independently of Snapshot's own computation, that: -- Every vote was signed by the voter it is listed under, names one of the proposal's choices and was cast while the vote was open. -- Every voter's power is what the [voting power](/dao/voting-power) rules give them at the proposal's block, delegations included. +- 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 (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 and a signed timestamp within their windows (see [Snapshot block](/dao/voting#snapshot-block)), and seven days of voting with no delay. +- 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. +`VERIFIED` means the listed votes and tally match the recomputation. +A verified result can still be a rejected proposal, so execution also requires `Outcome: passed`. A proposal that fails a check is not executed. The tool does not judge whether a proposal is a change of the rules of governance. diff --git a/docs/dao/voting-power.md b/docs/dao/voting-power.md index 8f4775d0..afffede1 100644 --- a/docs/dao/voting-power.md +++ b/docs/dao/voting-power.md @@ -15,7 +15,7 @@ Snapshot shows voting power under the symbol vpAPI3. ## What counts -At the block a proposal is measured at, your voting power is the sum of: +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. @@ -24,9 +24,14 @@ At the block a proposal is measured at, your voting power is the sum of: - API3 scheduled for unstaking, during the waiting period. - API3 posted as collateral in the Kabu API3/USDC market on Morpho. -Nothing else counts: API3 on an exchange, in a liquidity pool, lent out, bridged to another chain or held in any other contract. +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 @@ -59,26 +64,36 @@ There are two kinds, and they behave differently. | | Pool delegation | Snapshot delegation | | -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------ | -| Made in | The legacy DAO dashboard | The [Delegates](https://snapshot.box/#/s:api3.eth/delegates) page of the space | -| What is delegated | Your stake in the pool | Everything that counts for you, stake delegated to you in the pool included | +| 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 -Pool delegation is made in the legacy DAO dashboard and carries over for the stake only. +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 dashboard dates from the legacy DAO, and [its documentation](https://dao-docs.api3.org/members/voting.html) describes delegating in it. +The legacy dashboard is retained for unstaking and withdrawing. +To inspect or change a pool delegation, 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 everything that counts for you, stake that others delegated to you in the pool included. -You then vote with zero. +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. @@ -96,19 +111,21 @@ To delegate, open the [Delegates](https://snapshot.box/#/s:api3.eth/delegates) p - 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 nothing. - Your own power is zero while the delegation stands. - Clear it before the proposal's block to vote yourself. +- 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 everything with one delegate, delegate on Snapshot. - Pool delegation alone leaves the rest of your holdings with you. +- 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 or another contract wallet has voting power like any wallet and votes by signing through the Safe app (see [Voting](/dao/voting)). +- 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. -- An API3 multisig that pool delegations name votes with the stake delegated to it. -- The API3 multisig that holds the DAO's own API3 has its voting power delegated to a dead address and does not vote. +- 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 [DAO multisigs](/dao/#dao-multisigs)). diff --git a/docs/dao/voting.md b/docs/dao/voting.md index b47a0ba7..3bcea458 100644 --- a/docs/dao/voting.md +++ b/docs/dao/voting.md @@ -9,7 +9,7 @@ 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 a proposal is posted, with no delay, and closes seven days later. +Voting opens when Snapshot's sequencer accepts a proposal, with no delay, and closes seven days later. ## Casting a vote @@ -26,11 +26,14 @@ Votes are public while the proposal is open. - 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 own power is zero and voting yourself does nothing. - Clear the delegation before the proposal's block to vote yourself (see [Delegation](/dao/voting-power#delegation)). +- 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. -- A Safe or another contract wallet votes by signing through the Safe app. +- 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 @@ -44,17 +47,20 @@ Proposals are discussed on the [API3 Discord](https://discord.gg/api3dao). 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 at once. + Voting starts when the sequencer accepts the proposal. -To post from a Safe, open snapshot.box in the Safe app and collect the signatures within a day, since the proposal has to reach Snapshot within a day of the signed timestamp. +To post from a Safe, open snapshot.box in the Safe app, collect the required confirmations and deliver the signed proposal to Snapshot. +Check that the proposal appears in the space. +The snapshot block is fixed when preparing the proposal, so collect the confirmations and submit it while that block is still within the [allowed window](#snapshot-block). 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 not later than the signed timestamp and at most 7,200 blocks, about a day, before the proposal was posted. +The DAO's rule is that the block is not later than the proposal's creation and at most 7,200 blocks, about a day, before the proposal was posted. A proposal measured at a block outside this window is not verified and is not executed. -The signed timestamp has to precede Snapshot's receipt of the proposal by at most a day, since voting starts at the signed timestamp and a backdated signature would shorten the seven days. +Snapshot sets the creation time when the sequencer accepts the proposal. +A signed start time in the past is moved forward so that voting still runs for the full seven days (see [Snapshot assumptions](/dao/snapshot-assumptions#proposal-timing)). ## Passing @@ -69,21 +75,25 @@ The [independent verification](/dao/verification) applies the rule. Within seven days of passing, the API3 multisigs 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, the charter, the voting power strategy, the space settings, the verification tool, the `api3.eth` name and which multisig holds the treasury. +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 | +| 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/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 index 646d58a1..11d0b338 100644 --- a/scripts/check-dao-space.js +++ b/scripts/check-dao-space.js @@ -1,9 +1,10 @@ // Checks the DAO pages against the live api3.eth space on the Snapshot hub. -// The voting power page has to name every contract, method, field and market -// the space's strategies read, and nothing else, and the voting page has to -// carry the threshold, voting type, delay, period and quorum the space is set to. +// 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'; @@ -12,9 +13,16 @@ const daoDir = path.join(__dirname, '..', 'docs', 'dao'); const query = `{ space(id: "${SPACE}") { network + symbol strategies { name network params } - voting { type delay period quorum } + voting { type delay period quorum hideAbstain privacy } validation { name params } + voteValidation { name params } + filters { onlyMembers } + admins + members + moderators + treasuries { name address network } } }`; @@ -23,6 +31,7 @@ async function fetchSpace() { 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}`); @@ -76,26 +85,37 @@ function hasRow(page, setting, value) { return row.test(page); } -async function main() { - const space = await fetchSpace(); +function checkSpace(space, pages) { const problems = []; - const votingPowerPage = fs.readFileSync(path.join(daoDir, 'voting-power.md'), 'utf-8'); - const votingPage = fs.readFileSync(path.join(daoDir, 'voting.md'), 'utf-8'); + 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`); } - if (space.strategies.length !== 1 || space.strategies[0].name !== 'with-delegation') { + const strategies = space.strategies ?? []; + if (strategies.length !== 1 || strategies[0].name !== 'with-delegation') { problems.push( - `The space runs ${space.strategies.map((strategy) => strategy.name).join(', ')}, the voting power page describes one with-delegation strategy` + `The space runs ${strategies.map((strategy) => strategy.name).join(', ')}, the voting power page describes one with-delegation strategy` ); } - const sources = space.strategies[0]?.params?.strategies ?? []; + 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(space.strategies); + 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`); @@ -107,7 +127,6 @@ async function main() { } } - const tokenPage = fs.readFileSync(path.join(daoDir, 'api3-token.md'), 'utf-8'); 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'); @@ -120,17 +139,23 @@ async function main() { } } - if (space.validation.name !== 'basic') { + if (space.validation?.name !== 'basic') { problems.push( - `The space validates proposals with ${space.validation.name}, the proposals page describes a basic threshold` + `The space validates proposals with ${space.validation?.name}, the voting page describes a basic threshold` ); } + const voting = space.voting ?? {}; const rows = { - 'Voting type': space.voting.type === 'basic' ? 'Basic' : String(space.voting.type), - 'Proposal threshold': formatAmount(space.validation.params?.minScore), - 'Voting delay': space.voting.delay ? formatDays(space.voting.delay) : 'None', - 'Voting period': formatDays(space.voting.period), - Quorum: `${formatAmount(space.voting.quorum)} in favour`, + '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)) { @@ -138,6 +163,19 @@ async function main() { } } + 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}`)); @@ -146,7 +184,11 @@ async function main() { console.log(`The DAO pages match the ${SPACE} space on the hub.`); } -main().catch((error) => { - console.error(error.message); - process.exit(1); -}); +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..354c5b93 --- /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": 0, + "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" + } + ] +}