-
Notifications
You must be signed in to change notification settings - Fork 7
docs(torn): DAO anatomy, tech funnel & remaining-gap roadmap #2004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
753d616
53cd4e8
3666793
5ececaa
7605886
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| # Tornado Cash (TORN) — Research, Anatomy & Next Steps | ||
|
|
||
| > Companion to `INTEGRATION.md`. Independent research delivery to support PR #2002. | ||
| > Source of truth: `tornadocash/tornado-governance` (verified impl `GovernanceProposalStateUpgrade`, | ||
| > on-chain version `"5.proposal-state-patch"`), Etherscan, and on-chain reads. Date: 2026-06-29. | ||
|
|
||
| ## 0. Why now — the June 2026 attack | ||
|
|
||
| On **2026-06-25, Proposal 67** pointed execution at an **unverified look-alike contract** | ||
| (`0x5efda50f22d34f27…` vs the real governor `0x5efda50f22d34F26…` — differs only from the 16th | ||
| hex char) to swap governance control over a ~$23M TORN treasury. It drew **0 for / 27,163 against** | ||
| and is failing quorum (≈27% of the 100,000 TORN quorum), but it is the **second** attempt after the | ||
| May 2023 takeover. This is the motivation for the **proposal-target verification** work in §5. | ||
|
|
||
| --- | ||
|
|
||
| ## 1. Anatomy of the DAO | ||
|
|
||
| ### 1.1 Contract topology | ||
|
|
||
| | Role | Address | Type | Indexed? | | ||
| |---|---|---|---| | ||
| | Governance (proxy) | `0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce` | `LoopbackProxy` → `GovernanceProposalStateUpgrade` | ✅ events | | ||
| | TORN token | `0x77777FeDdddFfC19Ff86DB637967013e6C6A116C` | ERC20 + permit (no checkpoints) | ✅ Transfer | | ||
| | TornadoVault | `0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185` | holds locked TORN since v2 | ⚠️ balance only | | ||
| | Governance Staking | `0x2FC93484614a34f26F7970CBB94615bA109BB4bf` (proxy) | relayer-fee rewards | ❌ | | ||
| | Community Multisig | `0xb04E030140b30C27bcdfaafFFA98C57d80eDa7B4` | gas-comp only; cannot pass proposals | n/a | | ||
|
|
||
| ### 1.2 What makes it NOT a Governor Bravo | ||
|
|
||
| | Dimension | Standard (Bravo/ERC20Votes) | Tornado Cash | | ||
| |---|---|---| | ||
| | Voting power | `DelegateVotesChanged` checkpoints | `lockedBalance` mapping, **no event** | | ||
| | Acquire power | hold + self-delegate | **lock** TORN into governor (→ Vault) | | ||
| | Delegation | additive weight transfer | **pointer**; power = Σ delegators' locked, used only via `castDelegatedVote` | | ||
| | Vote support | `0/1/2` for/against/abstain | **bool** for/against, **no abstain** | | ||
| | Proposal | `targets[]/values[]/calldatas[]` | **single `target`**, run via `delegatecall executeProposal()` | | ||
| | Lifecycle | Timelock contract + `Queued`/`Canceled` | timestamp-derived `state()`, **no queue/cancel events**, no cancel fn | | ||
| | Params | typically fixed | **mutable** via passed proposal | | ||
|
|
||
| ### 1.3 On-chain parameters (live values — verify, they are mutable) | ||
|
|
||
| | Param | Live value | Notes | | ||
| |---|---|---| | ||
| | `QUORUM_VOTES` | **100,000 TORN** | raised from the 25,000 default | | ||
| | `PROPOSAL_THRESHOLD` | **1,000 TORN** | ⚠️ dashboard copy currently says 25,000 — see §6 | | ||
| | `VOTING_DELAY` | **75 s** | ⚠️ dashboard copy says ~1 block/12s — see §6 | | ||
| | `VOTING_PERIOD` | **432,000 s (5 d)** | (v1 default was 3 d) | | ||
| | `EXECUTION_DELAY` | 172,800 s (2 d) | built-in timelock | | ||
| | `EXECUTION_EXPIRATION` | 259,200 s (3 d) | execution window | | ||
| | `CLOSING_PERIOD` / `VOTE_EXTEND_TIME` | 3,600 s / 21,600 s | one-time 6h extension on late flip | | ||
|
|
||
| States: `Pending · Active · Defeated · Timelocked · AwaitingExecution · Executed · Expired` (no Canceled). | ||
| Quorum rule on-chain: **`forVotes + againstVotes >= QUORUM_VOTES` AND `forVotes > againstVotes`**. | ||
|
|
||
| ### 1.4 Governance lifecycle & events | ||
|
|
||
| | Step | Function | Event(s) | topic0 | | ||
| |---|---|---|---| | ||
| | Lock (gain power) | `lockWithApproval` / `lock` | none (signal: TORN `Transfer`→Vault, `RewardUpdate*`) | — | | ||
| | Delegate | `delegate` / `undelegate` | `Delegated` / `Undelegated` | `0x4bc154…` / `0x1af5b1…` | | ||
| | Propose | `propose` / `proposeByDelegate` | `ProposalCreated` | `0x90ec05…` | | ||
| | Vote | `castVote` / `castDelegatedVote` | `Voted` (one per voter) | `0x7c2de5…` | | ||
| | Timelock | — (implicit) | none | — | | ||
| | Execute | `execute` | `ProposalExecuted` | `0x712ae1…` | | ||
| | Unlock | `unlock` | none (signal: TORN `Transfer`←Vault) | — | | ||
|
|
||
| --- | ||
|
|
||
| ## 2. Tech funnel (data → interface) | ||
|
|
||
| ``` | ||
| ETHEREUM | ||
| Governance proxy ──ProposalCreated / Voted / ProposalExecuted / Delegated / Undelegated──┐ | ||
| TORN token ──Transfer───────────────────────────────────────────────────────────┐│ | ||
| │ (eth_call: QUORUM_VOTES, *_DELAY/PERIOD, EXECUTION_*) ││ | ||
| ▼ ▼▼ | ||
| [1] INDEXER apps/indexer/src/indexer/torn/{governor,erc20}.ts | ||
| • bool→0/1 support · single target→targets:[t] · timestamp→synthetic block | ||
| • delegatedSupply from Transfers in/out of governor | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For post-v2 TORN locks, tokens are transferred to/from TornadoVault, not just the governor; this document already lists the vault as holding locked TORN since v2 and later says to backfill from governor+Vault. If a follow-up uses this funnel literally, the indexer will keep watching only the governor address and miss new lock/unlock events, leaving delegatedSupply/account power stale. Please describe the signal as legacy governor plus TornadoVault transfers. Useful? React with 👍 / 👎. |
||
| ▼ writes | ||
| [2] POSTGRES (Ponder schema) proposalsOnchain · votesOnchain · accountPower · delegation · transfer · token | ||
| ▲ Drizzle (direct read) | ||
| [3] API apps/api (Hono REST · controller→service→repository→mapper · clients/) | ||
| • clients/torn/TORNClient extends GovernorBase (live quorum, timestamp state machine) | ||
| • eth_call for live params & status | ||
| ▼ exposes per-DAO OpenAPI | ||
| [4] GATEFUL apps/gateful (aggregates the per-DAO APIs; serves the merged OpenAPI) | ||
| ▼ live OpenAPI URL | ||
| [5] anticapture-client (kubb reads Gateful's OpenAPI → TanStack Query hooks) | ||
| ▼ | ||
| [6] DASHBOARD apps/dashboard (Next.js, white-label via middleware + shared/dao-config/torn.ts) | ||
| proposals · holders-and-delegates · attack-profitability · token-distribution · governance risk | ||
| ``` | ||
|
|
||
| > Pipeline order matters: **API → Gateful → `@anticapture/client` → Dashboard**. Kubb generates the | ||
| > client by reading Gateful's *live* OpenAPI URL (`packages/anticapture-client/kubb.config.ts:29-55`), | ||
| > not an API spec pushed forward — so any new TORN endpoint must reach Gateful before client codegen, | ||
| > or the generated hooks go stale / miss gateway-level DAO paths. | ||
|
|
||
| **White-label:** routing is already supported, but a custom domain needs **both** of these in `torn.ts`: | ||
| `hostnames: [...]` **and** a `whitelabel: {}` field. `middleware.ts` rewrites the hostname to | ||
| `/whitelabel/[daoId]`, but that route calls `notFound()` unless `isWhitelabelDao(daoConfig)` is true, | ||
| which requires `daoConfig.whitelabel` to be present (`shared/utils/whitelabel.ts:37-41`, | ||
| `app/whitelabel/[daoId]/layout.tsx:87-90`). `torn.ts` currently has **no** `whitelabel` field, so adding | ||
| only `hostnames` would 404 — add `whitelabel: {}` too. | ||
|
|
||
| --- | ||
|
|
||
| ## 3. What PR #2002 already delivers (do not redo) | ||
|
|
||
| - [x] Token supply + CEX/DEX/Lending/Treasury/NonCirculating classification + circulating supply | ||
| - [x] `delegatedSupply` (aggregate locked TORN) via Transfer detection | ||
| - [x] Governor delegation (`Delegated`/`Undelegated` → `delegateChanged`) | ||
| - [x] Proposals (custom timestamp handler), votes (binary), execution | ||
| - [x] `TORNClient` with live `QUORUM_VOTES` + timestamp-based status | ||
| - [x] Dashboard config: attack-profitability, governance-implementation risk matrix, attack-exposure, holders/delegates, token distribution | ||
| - [x] Backfill verified (65 proposals, 49 executed, 1,089 votes — matches chain) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This file is dated 2026-06-29 and opens by citing Proposal 67, so saying #2002's backfill “65 proposals ... matches chain” reads as current even though at least proposals 66/67 now exist. If maintainers rely on this to decide the integration is complete, they may skip re-running a backfill that should cover the newer proposals; mark these numbers as March 2026 verification or update them. Useful? React with 👍 / 👎. |
||
|
|
||
| --- | ||
|
|
||
| ## 4. Open gaps (from INTEGRATION.md, confirmed) | ||
|
|
||
| 1. **Per-account `votingPowerHistory` not populated** — only aggregate `delegatedSupply`. Holders/delegates | ||
| voting-power-over-time is therefore incomplete. | ||
| 2. **Vote extension not tracked** — `endTime` can shift +6h; indexer keeps initial `endTime`. | ||
| 3. **Staking rewards not tracked** — relayer-fee yield on locked TORN (economic only). | ||
| 4. **Proposal target not decoded** — `alreadySupportCalldataReview() = false`; target indexed but not analyzed. | ||
| 5. **abstain column** — always 0; hide for TORN in UI. | ||
|
|
||
| --- | ||
|
|
||
| ## 5. Recommended next steps (priority order) | ||
|
|
||
| ### P1 — Proposal-target verification (highest value; addresses Proposal 67) | ||
| The June 2026 attack was an **unverified look-alike target**. Add a detection signal: | ||
| - On `ProposalCreated`, enrich `target` via the existing **`apps/address-enrichment`** app: | ||
| - Etherscan **source-verified?** (unverified ⇒ HIGH risk) | ||
| - **address-similarity** vs a known-entity allowlist (governor/vault/token/multisig) — catches `…f27…` vs `…F26…` | ||
| - optional: bytecode decompile diff (declared vs actual) | ||
|
Comment on lines
+147
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Proposal 67, the spoofed Useful? React with 👍 / 👎. |
||
| - Surface as a `target_risk` field on proposal detail + a `feedEvent` flag → red banner. | ||
| - Flip `alreadySupportCalldataReview()` accordingly once decode lands. | ||
|
|
||
| ### P2 — Per-account voting power | ||
| - Use `RewardUpdateSuccessful(account)` (fires on every lock/unlock) as a "balance-changed" trigger to | ||
| `readContract lockedBalance(account)` and write `votingPowerHistory` + `accountPower.votingPower`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For TORN lock/unlock tracking, using only Useful? React with 👍 / 👎. |
||
| - Backfill via TORN `Transfer` to/from governor+Vault, reconciling delegation shifts. | ||
|
|
||
| ### P3 — Correctness fixes (see §6) | ||
| - Re-vote handling, `calculateQuorum`, vote extension, config copy. | ||
|
|
||
| --- | ||
|
|
||
| ## 6. Findings to verify against the contract (correctness) | ||
|
|
||
| > Flagging for maintainer review; each is a small, contained fix. | ||
|
|
||
| 1. **Re-votes are silently dropped.** Tornado `_castVote` **allows overwriting a vote** (it subtracts the old | ||
| receipt and adds the new). The indexer uses `onConflictDoNothing` on PK `(voterAccountId, proposalId)`, so a | ||
| changed vote is ignored — indexed tallies drift from chain (the "tallies may differ" note in INTEGRATION.md | ||
| is partly this). **Fix:** on conflict, recompute the tally (subtract old `votingPower`/side, add new) like the contract. | ||
|
|
||
| 2. **`calculateQuorum` returns `forVotes` only.** On-chain `state()` reaches quorum on | ||
| **`forVotes + againstVotes >= QUORUM_VOTES`**. Returning `forVotes` can mislabel `NO_QUORUM`. | ||
| **Fix:** `return votes.forVotes + votes.againstVotes;` | ||
|
|
||
| 3. **`rules.changeVote: false` / `VOTE_MUTABILITY` "does not allow changing votes"** contradicts the source — | ||
| re-voting is allowed while Active. **Fix:** set `changeVote: true` and update the risk copy (lowers that risk). | ||
|
|
||
| 4. **`PROPOSAL_THRESHOLD` copy says "25,000 TORN"** — actual is **1,000 TORN** (`1e21`). Likely confused with the | ||
| old 25,000 quorum. **Fix:** correct the dashboard string. | ||
|
|
||
| 5. **`VOTING_DELAY` copy says "~1 block (~12s)"** — actual is **75 s**. Minor; align copy. | ||
|
|
||
| 6. **Add the June 2026 (Proposal 67) attack** to INTEGRATION.md notes alongside May 2023 — it is the live | ||
| motivation for P1. | ||
|
|
||
| --- | ||
|
|
||
| ## 7. Effort summary | ||
|
|
||
| | Item | Layer | Size | | ||
| |---|---|---| | ||
| | P1 target verification | address-enrichment + api + dashboard | M | | ||
| | P2 per-account voting power | indexer (readContract on RewardUpdate*) | M | | ||
| | Re-vote tally fix | indexer governor.ts | S | | ||
| | calculateQuorum fix | api clients/torn | XS | | ||
| | Config copy fixes (#3–#5) | dashboard torn.ts | XS | | ||
| | abstain column hide | dashboard | XS | | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The reported proposal 67 attacker-controlled address is
0x5efda50f22d34f272c7077689d6abc42f15e285f, so it shares the first 15 hex characters with the governor and then diverges; it is not a one-character edit of the full governor address (see the Foresight/HTX report: https://www.htx.com/news/tornado-cash-suffers-another-governance-attack-a-fake-propos-w8ZWBHYX/). Because §5 turns this incident into the requirements for an address-similarity detector, describing it as “differs only from the 16th hex char” can lead implementers to look for single-character typos instead of vanity-prefix spoofing; please reword this to say it shares the first 15 chars and diverges at the 16th.Useful? React with 👍 / 👎.