diff --git a/.changeset/tornado-cash-integration.md b/.changeset/tornado-cash-integration.md new file mode 100644 index 000000000..d38972c71 --- /dev/null +++ b/.changeset/tornado-cash-integration.md @@ -0,0 +1,7 @@ +--- +"@anticapture/indexer": minor +"@anticapture/api": minor +"@anticapture/dashboard": minor +--- + +Integrate Tornado Cash DAO (TORN): custom stake-to-vote indexer (lock-based delegated supply, timestamp governance), timestamp-based proposal-status API client, and dashboard config/icon. diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 390fd73b8..a97f4f5f3 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -181,7 +181,7 @@ if (!daoClient) { } const pgClient = drizzle(env.DATABASE_URL, { - schema, + schema: { ...schema, ...offchainSchema }, casing: "snake_case", }); @@ -396,32 +396,28 @@ if (env.DAO_ID === DaoIdEnum.ENS) { revenue(app, revenueDuneClient); } -if (daoClient.supportOffchainData()) { - const pgUnifiedClient = drizzle(env.DATABASE_URL, { - schema: { ...schema, ...offchainSchema }, - casing: "snake_case", - }); - - const offchainProposalsRepo = wrapWithTracing( - new OffchainProposalRepository(pgUnifiedClient), - ); - const offchainVotesRepo = wrapWithTracing( - new OffchainVoteRepository(pgUnifiedClient), - ); - offchainProposals( - app, - wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), - ); - offchainVotes( - app, - wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), - ); +const supportOffchain = daoClient.supportOffchainData(); +const offchainProposalsRepo = wrapWithTracing( + new OffchainProposalRepository(pgClient), +); +const offchainVotesRepo = wrapWithTracing(new OffchainVoteRepository(pgClient)); +offchainProposals( + app, + wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), + supportOffchain, +); +offchainVotes( + app, + wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), + supportOffchain, +); - const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl( - pgUnifiedClient, - ); - offchainNonVoters(app, new OffchainNonVotersService(offchainNonVotersRepo)); -} +const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl(pgClient); +offchainNonVoters( + app, + wrapWithTracing(new OffchainNonVotersService(offchainNonVotersRepo)), + supportOffchain, +); serve( { diff --git a/apps/api/src/clients/index.ts b/apps/api/src/clients/index.ts index f9ab39998..66f387d9d 100644 --- a/apps/api/src/clients/index.ts +++ b/apps/api/src/clients/index.ts @@ -10,6 +10,7 @@ export * from "./uni"; export * from "./shu"; export * from "./aave"; export * from "./fluid"; +export * from "./torn"; export interface DAOClient { getDaoId: () => string; diff --git a/apps/api/src/clients/torn/abi.ts b/apps/api/src/clients/torn/abi.ts new file mode 100644 index 000000000..8d5bcc761 --- /dev/null +++ b/apps/api/src/clients/torn/abi.ts @@ -0,0 +1,44 @@ +export const TORNGovernorAbi = [ + { + type: "function", + name: "QUORUM_VOTES", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_PERIOD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "PROPOSAL_THRESHOLD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_EXPIRATION", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/apps/api/src/clients/torn/index.ts b/apps/api/src/clients/torn/index.ts new file mode 100644 index 000000000..8658409dc --- /dev/null +++ b/apps/api/src/clients/torn/index.ts @@ -0,0 +1,203 @@ +import { Account, Address, Chain, Client, Transport } from "viem"; + +import { DAOClient } from "@/clients"; +import { ProposalStatus } from "@/lib/constants"; + +import { GovernorBase } from "../governor.base"; + +import { TORNGovernorAbi } from "./abi"; + +const BLOCK_TIME = 12; + +export class TORNClient< + TTransport extends Transport = Transport, + TChain extends Chain = Chain, + TAccount extends Account | undefined = Account | undefined, +> + extends GovernorBase + implements DAOClient +{ + protected address: Address; + protected abi: typeof TORNGovernorAbi; + + constructor(client: Client, address: Address) { + super(client); + this.address = address; + this.abi = TORNGovernorAbi; + } + + getDaoId(): string { + return "TORN"; + } + + async getQuorum(_proposalId: string | null): Promise { + return this.getCachedQuorum(async () => { + return this.readContract({ + abi: this.abi, + address: this.address, + functionName: "QUORUM_VOTES", + }); + }); + } + + // TORN governance times everything in seconds, but the platform contract + // for getVotingDelay/getVotingPeriod expects BLOCK units (consumers multiply + // by blockTime to recover seconds). Convert seconds → blocks on read. + async getVotingDelay(): Promise { + if (!this.cache.votingDelay) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_DELAY", + })) as bigint; + this.cache.votingDelay = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingDelay!; + } + + async getVotingPeriod(): Promise { + if (!this.cache.votingPeriod) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_PERIOD", + })) as bigint; + this.cache.votingPeriod = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingPeriod!; + } + + async getProposalThreshold(): Promise { + if (!this.cache.proposalThreshold) { + this.cache.proposalThreshold = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "PROPOSAL_THRESHOLD", + })) as bigint; + } + return this.cache.proposalThreshold!; + } + + async getTimelockDelay(): Promise { + if (!this.cache.timelockDelay) { + this.cache.timelockDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_DELAY", + })) as bigint; + } + return this.cache.timelockDelay!; + } + + private async getExecutionExpiration(): Promise { + if (!this.cache.executionPeriod) { + this.cache.executionPeriod = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_EXPIRATION", + })) as bigint; + } + return this.cache.executionPeriod!; + } + + calculateQuorum(votes: { + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + }): bigint { + // Tornado's Governance.state() reaches quorum on forVotes + againstVotes + // (both sides count toward QUORUM_VOTES), not forVotes alone. + return votes.forVotes + votes.againstVotes; + } + + alreadySupportCalldataReview(): boolean { + return false; + } + + /** + * Tornado Cash proposal status — timestamp-based, not block-based. + * + * The governor uses seconds for all timing parameters (VOTING_DELAY, + * VOTING_PERIOD, EXECUTION_DELAY, EXECUTION_EXPIRATION). We derive + * startTime synthetically from endTimestamp and the block range. + * + * State machine: + * EXECUTED → finalized (persisted by indexer) + * now < startTime → PENDING + * now < endTimestamp → ACTIVE + * forVotes + againstVotes < quorum → NO_QUORUM + * forVotes <= againstVotes → DEFEATED + * now < endTimestamp + EXECUTION_DELAY → QUEUED + * now < endTimestamp + EXECUTION_DELAY + EXECUTION_EXPIRATION → PENDING_EXECUTION + * else → EXPIRED + */ + async getProposalStatus( + proposal: { + id: string; + status: string; + startBlock: number; + endBlock: number; + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + endTimestamp: bigint; + }, + _currentBlock: number, + currentTimestamp: number, + ): Promise { + // Already finalized via event + if (proposal.status === ProposalStatus.EXECUTED) { + return ProposalStatus.EXECUTED; + } + + if (proposal.status === ProposalStatus.CANCELED) { + return ProposalStatus.CANCELED; + } + + const now = BigInt(currentTimestamp); + const endTimestamp = proposal.endTimestamp; + + // Estimate startTime from endTimestamp and block range + const votingDurationSeconds = + BigInt(proposal.endBlock - proposal.startBlock) * BigInt(BLOCK_TIME); + const startTime = endTimestamp - votingDurationSeconds; + + if (now < startTime) { + return ProposalStatus.PENDING; + } + + if (now < endTimestamp) { + return ProposalStatus.ACTIVE; + } + + // After voting period ends — check quorum and majority + const quorum = await this.getQuorum(proposal.id); + const proposalQuorum = this.calculateQuorum({ + forVotes: proposal.forVotes, + againstVotes: proposal.againstVotes, + abstainVotes: proposal.abstainVotes, + }); + + if (proposalQuorum < quorum) { + return ProposalStatus.NO_QUORUM; + } + + if (proposal.forVotes <= proposal.againstVotes) { + return ProposalStatus.DEFEATED; + } + + // Passed — check timelock windows + const executionDelay = await this.getTimelockDelay(); + const executionExpiration = await this.getExecutionExpiration(); + + if (now < endTimestamp + executionDelay) { + return ProposalStatus.QUEUED; + } + + if (now < endTimestamp + executionDelay + executionExpiration) { + return ProposalStatus.PENDING_EXECUTION; + } + + return ProposalStatus.EXPIRED; + } +} diff --git a/apps/api/src/controllers/proposals/offchainProposals.ts b/apps/api/src/controllers/proposals/offchainProposals.ts index 90d1925d1..f63866f48 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.ts @@ -16,6 +16,7 @@ import { OffchainProposalsService } from "@/services"; export function offchainProposals( app: Hono, service: OffchainProposalsService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -39,9 +40,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, orderDirection, status, fromDate, endDate, lean } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainNonVoters.ts b/apps/api/src/controllers/votes/offchainNonVoters.ts index f7a101215..d2d59ab6f 100644 --- a/apps/api/src/controllers/votes/offchainNonVoters.ts +++ b/apps/api/src/controllers/votes/offchainNonVoters.ts @@ -10,6 +10,7 @@ import { OffchainNonVotersService } from "@/services"; export function offchainNonVoters( app: Hono, service: OffchainNonVotersService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -35,6 +36,14 @@ export function offchainNonVoters( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, 404: { description: "Proposal not found", content: { @@ -46,6 +55,13 @@ export function offchainNonVoters( }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { skip, limit, orderDirection, addresses } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainVotes.ts b/apps/api/src/controllers/votes/offchainVotes.ts index a8e990deb..6926c8ff4 100644 --- a/apps/api/src/controllers/votes/offchainVotes.ts +++ b/apps/api/src/controllers/votes/offchainVotes.ts @@ -1,6 +1,7 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; import { + ErrorResponseSchema, OffchainProposalRequestSchema, OffchainVotesRequestSchema, OffchainVotesResponseSchema, @@ -8,7 +9,11 @@ import { import { setCacheControl } from "@/middlewares"; import { OffchainVotesService } from "@/services"; -export function offchainVotes(app: Hono, service: OffchainVotesService) { +export function offchainVotes( + app: Hono, + service: OffchainVotesService, + supportOffchain: boolean, +) { app.openapi( createRoute({ method: "get", @@ -30,9 +35,24 @@ export function offchainVotes(app: Hono, service: OffchainVotesService) { }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, diff --git a/apps/api/src/lib/client.ts b/apps/api/src/lib/client.ts index 32792b00a..cc8c11b7b 100644 --- a/apps/api/src/lib/client.ts +++ b/apps/api/src/lib/client.ts @@ -14,6 +14,7 @@ import { SHUClient, AAVEClient, FLUIDClient, + TORNClient, } from "@/clients"; import { CONTRACT_ADDRESSES } from "./constants"; @@ -84,6 +85,10 @@ export function getClient< case DaoIdEnum.AAVE: { return new AAVEClient(client); } + case DaoIdEnum.TORN: { + const { governor } = CONTRACT_ADDRESSES[daoId]; + return new TORNClient(client, governor.address); + } default: return null; } diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 4341f9e63..ab6ed79e3 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -220,6 +220,19 @@ export const CONTRACT_ADDRESSES = { startBlock: 12422079, }, }, + [DaoIdEnum.TORN]: { + blockTime: 12, + tokenType: "ERC20", + token: { + address: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + decimals: 18, + startBlock: 11474599, + }, + governor: { + address: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + startBlock: 11474695, + }, + }, } as const; export const TreasuryAddresses: Record> = { @@ -390,6 +403,7 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + [DaoIdEnum.TORN]: {}, }; export enum ProposalStatus { diff --git a/apps/api/src/lib/enums.ts b/apps/api/src/lib/enums.ts index b45c20d6c..025878d72 100644 --- a/apps/api/src/lib/enums.ts +++ b/apps/api/src/lib/enums.ts @@ -13,6 +13,7 @@ export enum DaoIdEnum { OBOL = "OBOL", ZK = "ZK", FLUID = "FLUID", + TORN = "TORN", } export const SECONDS_IN_DAY = 24 * 60 * 60; diff --git a/apps/api/src/lib/eventRelevance.ts b/apps/api/src/lib/eventRelevance.ts index f0599ea20..9aa8c7010 100644 --- a/apps/api/src/lib/eventRelevance.ts +++ b/apps/api/src/lib/eventRelevance.ts @@ -232,6 +232,13 @@ const DAO_RELEVANCE_THRESHOLDS: Record = { [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, }, + [DaoIdEnum.TORN]: { + [FeedEventType.TRANSFER]: EMPTY_THRESHOLDS, + [FeedEventType.DELEGATION]: EMPTY_THRESHOLDS, + [FeedEventType.VOTE]: EMPTY_THRESHOLDS, + [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, + [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, + }, }; export function getDaoRelevanceThreshold(daoId: DaoIdEnum): EventRelevanceMap { diff --git a/apps/api/src/services/coingecko/types.ts b/apps/api/src/services/coingecko/types.ts index 8bb6f7667..d8fb2fd62 100644 --- a/apps/api/src/services/coingecko/types.ts +++ b/apps/api/src/services/coingecko/types.ts @@ -26,6 +26,7 @@ export const CoingeckoTokenIdEnum: Record = { ZK: "zksync", SHU: "shutter", FLUID: "fluid", + TORN: "tornado-cash", } as const; export const CoingeckoIdToAssetPlatformId = { @@ -41,6 +42,7 @@ export const CoingeckoIdToAssetPlatformId = { [CoingeckoTokenIdEnum.ZK]: AssetPlatformEnum.ZKSYNC, [CoingeckoTokenIdEnum.SHU]: AssetPlatformEnum.ETHEREUM, [CoingeckoTokenIdEnum.FLUID]: AssetPlatformEnum.ETHEREUM, + [CoingeckoTokenIdEnum.TORN]: AssetPlatformEnum.ETHEREUM, } as const; export interface CoingeckoHistoricalMarketData { diff --git a/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx b/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx new file mode 100644 index 000000000..df0ee9df1 --- /dev/null +++ b/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx @@ -0,0 +1,39 @@ +import type { DaoIconProps } from "@/shared/components/icons/types"; + +export const TornadoCashIcon = ({ + showBackground = true, + ...props +}: DaoIconProps) => { + return ( + + {showBackground && } + + + + + + + + + ); +}; diff --git a/apps/dashboard/shared/components/icons/index.ts b/apps/dashboard/shared/components/icons/index.ts index 3f71b11bc..8f592c354 100644 --- a/apps/dashboard/shared/components/icons/index.ts +++ b/apps/dashboard/shared/components/icons/index.ts @@ -15,6 +15,8 @@ export * from "@/shared/components/icons/ScrollIcon"; export * from "@/shared/components/icons/CompoundIcon"; export * from "@/shared/components/icons/ObolIcon"; export * from "@/shared/components/icons/ShutterIcon"; +export * from "@/shared/components/icons/TornadoCashIcon"; +// THE IMPORT OF DAO AVATAR ICON MUST BE LAST export * from "@/shared/components/icons/DaoAvatarIcon"; export * from "@/shared/components/icons/CookieBackground"; diff --git a/apps/dashboard/shared/dao-config/index.ts b/apps/dashboard/shared/dao-config/index.ts index e99c763b2..fd6581f7b 100644 --- a/apps/dashboard/shared/dao-config/index.ts +++ b/apps/dashboard/shared/dao-config/index.ts @@ -8,6 +8,7 @@ import { OBOL } from "@/shared/dao-config/obol"; import { OP } from "@/shared/dao-config/op"; import { SCR } from "@/shared/dao-config/scr"; import { SHU } from "@/shared/dao-config/shu"; +import { TORN } from "@/shared/dao-config/torn"; import type { DaoConfiguration } from "@/shared/dao-config/types"; import { UNI } from "@/shared/dao-config/uni"; import { AAVE } from "@/shared/dao-config/aave"; @@ -78,6 +79,7 @@ const rawDaoConfigByDaoId = { COMP, OBOL, SHU, + TORN, } as const; const daoConfigByDaoId = Object.fromEntries( diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts new file mode 100644 index 000000000..8ba41d7a8 --- /dev/null +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -0,0 +1,328 @@ +import { mainnet } from "viem/chains"; + +import { TornadoCashIcon } from "@/shared/components/icons"; +import { MainnetIcon } from "@/shared/components/icons/MainnetIcon"; +import { GOVERNANCE_IMPLEMENTATION_CONSTANTS } from "@/shared/constants/governance-implementations"; +import { RECOMMENDED_SETTINGS } from "@/shared/constants/recommended-settings"; +import type { DaoConfiguration } from "@/shared/dao-config/types"; +import { TornadoCashOgIcon } from "@/shared/og/dao-og-icons"; +import { + RiskLevel, + GovernanceImplementationEnum, + RiskAreaEnum, +} from "@/shared/types/enums"; + +export const TORN: DaoConfiguration = { + name: "Tornado Cash", + decimals: 18, + color: { + svgColor: "#94FEBF", + svgBgColor: "#1a1a2e", + }, + icon: TornadoCashIcon, + ogIcon: TornadoCashOgIcon, + daoOverview: { + token: "ERC20", + chain: { ...mainnet, icon: MainnetIcon }, + contracts: { + governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + token: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + }, + rules: { + delay: true, + changeVote: true, + timelock: true, + cancelFunction: false, + logic: "All Votes Cast", + quorumCalculation: "Fixed at 100,000 TORN", + }, + }, + attackProfitability: { + riskLevel: RiskLevel.MEDIUM, + supportsLiquidTreasuryCall: true, + }, + governanceImplementation: { + fields: { + [GovernanceImplementationEnum.AUDITED_CONTRACTS]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.AUDITED_CONTRACTS + ].description, + currentSetting: + "The Tornado Cash DAO contracts have been audited, and the audit is publicly available.", + impact: + "With its governance contracts audited, the risk of vulnerabilities in them is minimized.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.AUDITED_CONTRACTS], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.INTERFACE_RESILIENCE]: { + riskLevel: RiskLevel.MEDIUM, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.INTERFACE_RESILIENCE + ].description, + currentSetting: + "The Tornado Cash governance interface follows web2 standard protections with a secure HTTPS connection.", + impact: + "The governance interface domain shows basic security certificates, but without immutable decentralized storage it is not censorship-resistant or verifiable.", + recommendedSetting: + RECOMMENDED_SETTINGS[ + GovernanceImplementationEnum.INTERFACE_RESILIENCE + ], + nextStep: + "The Tornado Cash governance interface should be hosted on IPFS for censorship resistance.", + }, + [GovernanceImplementationEnum.ATTACK_PROFITABILITY]: { + riskLevel: RiskLevel.MEDIUM, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.ATTACK_PROFITABILITY + ].description, + currentSetting: + "Tornado Cash has a treasury managed by the DAO. The cost to accumulate governance power is moderate relative to treasury holdings.", + impact: + "A treasury creates financial incentives for an attacker to take over governance power, despite the cost required to accumulate sufficient voting weight.", + recommendedSetting: + RECOMMENDED_SETTINGS[ + GovernanceImplementationEnum.ATTACK_PROFITABILITY + ], + nextStep: + "Increasing delegation participation raises the cost of attacking the DAO and reduces the potential profitability of an attack.", + }, + [GovernanceImplementationEnum.PROPOSAL_FLASHLOAN_PROTECTION]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.PROPOSAL_FLASHLOAN_PROTECTION + ].description, + currentSetting: + "It protects the DAO from a flash loan aimed at reaching the Proposal Threshold and submitting a proposal, by taking a snapshot of the governance power from delegates/holders one block before the proposal submission.", + impact: + "It is not possible to use a flash loan to reach the amount required to submit a proposal.", + recommendedSetting: + RECOMMENDED_SETTINGS[ + GovernanceImplementationEnum.PROPOSAL_FLASHLOAN_PROTECTION + ], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.PROPOSAL_THRESHOLD]: { + riskLevel: RiskLevel.MEDIUM, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.PROPOSAL_THRESHOLD + ].description, + currentSetting: "The Proposal Threshold is set to 1,000 TORN.", + impact: + "Tornado Cash has a proposal threshold that adds a cost barrier to submitting proposals, but it may not be high enough relative to circulating supply to fully deter spam.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.PROPOSAL_THRESHOLD], + nextStep: + "The Proposal Threshold can be increased to a value above 1% of market supply to raise the cost of submitting proposals and reduce the likelihood of spam.", + requirements: [ + "A low proposal threshold lets attackers or small coalitions submit governance actions too easily, forcing the DAO to vote on spam or malicious items.", + "The DAO should set the proposal threshold at a level where only wallets with meaningful economic stake can create proposals.", + ], + }, + [GovernanceImplementationEnum.PROPOSER_BALANCE_CANCEL]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.PROPOSER_BALANCE_CANCEL + ].description, + currentSetting: + "Proposals cannot be canceled. There is no cancel function available in the Tornado Cash governance contract.", + impact: + "An attacker can buy tokens to submit a proposal in the DAO, vote with them, and sell them during the voting period. There is nothing in Tornado Cash governance that protects against this or prevents the attacker from doing so.", + recommendedSetting: + RECOMMENDED_SETTINGS[ + GovernanceImplementationEnum.PROPOSER_BALANCE_CANCEL + ], + nextStep: + "The governance contract should allow for permissionless cancel of a proposal if the address that submitted it has a governance token balance below the Proposal Threshold.", + requirements: [ + "Once a proposal is submitted, the proposer can immediately dump their tokens, reducing their financial risk in case of an attack.", + "The DAO must enforce a permissionless way to cancel any live proposal if the proposer's voting power drops below the proposal-creation threshold.", + ], + }, + [GovernanceImplementationEnum.SECURITY_COUNCIL]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.SECURITY_COUNCIL + ].description, + currentSetting: + "Tornado Cash does not have a Security Council or multisig with the authority to veto malicious proposals.", + impact: + "Without a Security Council, there is no backstop to prevent malicious proposals from being executed once they pass a governance vote.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.SECURITY_COUNCIL], + nextStep: + "A Security Council should be established with the authority to veto malicious proposals.", + }, + [GovernanceImplementationEnum.SPAM_RESISTANCE]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.SPAM_RESISTANCE + ].description, + currentSetting: + "There is no limit to the number of proposals that a single address can submit in the DAO.", + impact: + "A single address can submit multiple proposals, potentially masking an attack within one of them or make multiple malicious proposals.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.SPAM_RESISTANCE], + nextStep: + "It is necessary to limit the number of proposals that can be submitted by a single address.", + requirements: [ + "An attacker can swamp the system with simultaneous proposals, overwhelming voters to approve an attack through a war of attrition.", + "The DAO should impose—and automatically enforce—a hard cap on the number of active proposals any single address can have at once.", + ], + }, + [GovernanceImplementationEnum.TIMELOCK_DELAY]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.TIMELOCK_DELAY + ].description, + currentSetting: + "The execution delay for an approved proposal is 2 days (built into the governor contract).", + impact: + "There is a protected delay between proposal approval and execution.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.TIMELOCK_DELAY], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.VETO_STRATEGY]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VETO_STRATEGY + ].description, + currentSetting: + "Tornado Cash does not have a veto strategy or Security Council capable of blocking malicious proposals.", + impact: + "Without a veto mechanism, the DAO has no last line of defense against malicious proposals that manage to pass a governance vote.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VETO_STRATEGY], + nextStep: + "A veto mechanism or Security Council should be established to protect against malicious proposals.", + }, + [GovernanceImplementationEnum.VOTE_MUTABILITY]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTE_MUTABILITY + ].description, + currentSetting: + "The DAO allows voters to change their vote while a proposal is active; re-casting overwrites the prior ballot.", + impact: + "Voters can revise their vote until the voting period ends — e.g. to revert a ballot if a malicious proposal or an interface compromise is discovered.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTE_MUTABILITY], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.VOTING_DELAY]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTING_DELAY + ].description, + currentSetting: + "The Voting Delay is set to 75 seconds.", + impact: + "The Voting Delay period is extremely short. This gives delegates and stakeholders little time to coordinate their votes and for the DAO to protect itself against an attack. This poses a critical governance risk.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTING_DELAY], + nextStep: + "The Voting Delay needs to be increased to at least 2 days in order to be considered Medium Risk.", + requirements: [ + "Voting delay is the time between proposal submission and the snapshot that fixes voting power. The current near-zero delay lets attackers rush proposals before token-holders or delegates can react.", + "The DAO should enforce a delay of at least two full days and have an automatic alert plan that notifies major voters the moment a proposal is posted.", + ], + }, + [GovernanceImplementationEnum.VOTING_FLASHLOAN_PROTECTION]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTING_FLASHLOAN_PROTECTION + ].description, + currentSetting: + "It protects the DAO from a flash loan aimed to increase their voting power, by taking a snapshot of the governance power from delegates/holders one block before the Voting Period starts.", + impact: + "It is not possible to use a flash loan to increase voting power and approve a proposal.", + recommendedSetting: + RECOMMENDED_SETTINGS[ + GovernanceImplementationEnum.VOTING_FLASHLOAN_PROTECTION + ], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.VOTING_PERIOD]: { + riskLevel: RiskLevel.LOW, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTING_PERIOD + ].description, + currentSetting: "The Voting Period is set to approximately 5 days.", + impact: + "The current Voting Period is sufficient for governance participants to cast their votes.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTING_PERIOD], + nextStep: "The parameter is in its lowest-risk condition.", + }, + [GovernanceImplementationEnum.VOTING_SUBSIDY]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTING_SUBSIDY + ].description, + currentSetting: + "There is no subsidy to help voters participate in governance voting.", + impact: + "Without subsidizing governance participants' voting costs, there is lower participation and weaker incentives for delegates to protect the DAO, since they must incur gas fees to vote.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTING_SUBSIDY], + nextStep: + "A voting subsidy should be implemented to lower the barrier to participation in on-chain proposals.", + requirements: [ + "Without gas subsidies, smaller delegates face economic barriers to voting, reducing turnout and making it easier for well-funded attackers to dominate governance.", + "The DAO should provide gas-free voting to ensure broad participation.", + ], + }, + }, + }, + attackExposure: { + defenseAreas: { + [RiskAreaEnum.SPAM_RESISTANCE]: { + description: + "Proposal submissions are unrestricted and there is no voting subsidy, significantly reducing resistance to sustained proposal spam and lowering defensive participation.", + }, + [RiskAreaEnum.ECONOMIC_SECURITY]: { + description: + "The treasury managed by the DAO creates financial incentives for attack. Without a Security Council or veto mechanism, economic risk is elevated.", + }, + [RiskAreaEnum.SAFEGUARDS]: { + description: + "Proposals cannot be canceled at all in Tornado Cash governance, and there is no Security Council or veto strategy to block malicious proposals.", + }, + [RiskAreaEnum.CONTRACT_SAFETY]: { + description: + "Audited contracts and flash loan protections provide a solid foundation for contract safety.", + }, + [RiskAreaEnum.RESPONSE_TIME]: { + description: + "An extremely short voting delay leaves little time for review or coordination, increasing the risk of rushed or unchallenged governance decisions.", + }, + [RiskAreaEnum.GOV_FRONTEND_RESILIENCE]: { + description: + "Interface protections are present but not fully hardened (e.g. not served from immutable/decentralized storage), resulting in moderate governance interface risk. Mutable votes do allow recovery if a front-end compromise is caught during the voting window.", + }, + }, + }, + resilienceStages: true, + tokenDistribution: true, + dataTables: true, + overviewPage: false, + governancePage: true, +}; diff --git a/apps/dashboard/shared/og/dao-og-icons.tsx b/apps/dashboard/shared/og/dao-og-icons.tsx index efff5a81d..d28167d45 100644 --- a/apps/dashboard/shared/og/dao-og-icons.tsx +++ b/apps/dashboard/shared/og/dao-og-icons.tsx @@ -278,3 +278,30 @@ export function EnsOgArt({ height, color = DEFAULT_FILL }: OgArtProps) { ); } + +export function TornadoCashOgIcon({ size, color = DEFAULT_FILL }: OgIconProps) { + return ( + + + + + + + + + + ); +} diff --git a/apps/dashboard/shared/types/daos.ts b/apps/dashboard/shared/types/daos.ts index f5ce324a0..a4b311106 100644 --- a/apps/dashboard/shared/types/daos.ts +++ b/apps/dashboard/shared/types/daos.ts @@ -11,6 +11,7 @@ export enum DaoIdEnum { // OPTIMISM = "OP", UNISWAP = "UNI", GITCOIN = "GTC", + TORN = "TORN", } export const ALL_DAOS = Object.values(DaoIdEnum); diff --git a/apps/indexer/config/torn.config.ts b/apps/indexer/config/torn.config.ts new file mode 100644 index 000000000..915c3c455 --- /dev/null +++ b/apps/indexer/config/torn.config.ts @@ -0,0 +1,37 @@ +import { createConfig } from "ponder"; + +import { CONTRACT_ADDRESSES } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; +import { env } from "@/env"; +import { TORNTokenAbi, TORNGovernorAbi } from "@/indexer/torn/abi"; + +const TORN_CONTRACTS = CONTRACT_ADDRESSES[DaoIdEnum.TORN]; + +export default createConfig({ + database: { + kind: "postgres", + connectionString: env.DATABASE_URL, + }, + chains: { + ethereum_mainnet: { + id: 1, + rpc: env.RPC_URL, + maxRequestsPerSecond: env.MAX_REQUESTS_PER_SECOND, + pollingInterval: env.POLLING_INTERVAL, + }, + }, + contracts: { + TORNToken: { + abi: TORNTokenAbi, + chain: "ethereum_mainnet", + address: TORN_CONTRACTS.token.address, + startBlock: TORN_CONTRACTS.token.startBlock, + }, + TORNGovernor: { + abi: TORNGovernorAbi, + chain: "ethereum_mainnet", + address: TORN_CONTRACTS.governor.address, + startBlock: TORN_CONTRACTS.governor.startBlock, + }, + }, +}); diff --git a/apps/indexer/ponder.config.ts b/apps/indexer/ponder.config.ts index 57cc70f94..83b226792 100644 --- a/apps/indexer/ponder.config.ts +++ b/apps/indexer/ponder.config.ts @@ -10,6 +10,7 @@ import obolConfig from "./config/obol.config"; import optimismConfig from "./config/optimism.config"; import scrollConfig from "./config/scroll.config"; import shutterConfig from "./config/shutter.config"; +import tornConfig from "./config/torn.config"; import uniswapConfig from "./config/uniswap.config"; import zkConfig from "./config/zk.config"; @@ -29,6 +30,7 @@ export default { ...obolConfig.chains, ...zkConfig.chains, ...shutterConfig.chains, + ...tornConfig.chains, }, contracts: { ...aaveConfig.contracts, @@ -45,5 +47,6 @@ export default { ...obolConfig.contracts, ...zkConfig.contracts, ...shutterConfig.contracts, + ...tornConfig.contracts, }, }; diff --git a/apps/indexer/src/eventHandlers/delegation.ts b/apps/indexer/src/eventHandlers/delegation.ts index d3a34f89c..b4aadf69f 100644 --- a/apps/indexer/src/eventHandlers/delegation.ts +++ b/apps/indexer/src/eventHandlers/delegation.ts @@ -229,3 +229,59 @@ export const delegatedVotesChanged = async ( timestamp, }); }; + +/** + * Tornado Cash voting power is the account's `lockedBalance` in the Governance + * contract — there is NO DelegateVotesChanged event. We derive each account's + * voting power from lock/unlock token Transfers (TORN moving in/out of the + * governor pre-v2 or the TornadoVault post-v2). `delta` is +amount on lock and + * -amount on unlock. Deterministic, no external calls (indexer-skill safe). + * + * NOTE (future refinement): the exact source of truth is `lockedBalance(account)` + * read on the `RewardUpdateSuccessful`/`RewardUpdateFailed` events. The + * Transfer-derived value here matches it unless a lock/unlock occurs without a + * net token movement (not possible for lock/unlock today). See RESEARCH.md §5. + */ +export const lockedVotingPowerChanged = async ( + context: Context, + daoId: DaoIdEnum, + args: { + account: Address; + delta: bigint; + txHash: Hex; + timestamp: bigint; + logIndex: number; + }, +) => { + const { account, delta, txHash, timestamp, logIndex } = args; + const normalizedAccount = getAddress(account); + + await ensureAccountExists(context, account); + + const { votingPower: newBalance } = await context.db + .insert(accountPower) + .values({ + accountId: normalizedAccount, + daoId, + votingPower: delta > 0n ? delta : 0n, + }) + .onConflictDoUpdate((current) => ({ + votingPower: current.votingPower + delta, + })); + + const deltaMod = delta > 0n ? delta : -delta; + + await context.db + .insert(votingPowerHistory) + .values({ + daoId, + transactionHash: txHash, + accountId: normalizedAccount, + votingPower: newBalance, + delta, + deltaMod, + timestamp, + logIndex, + }) + .onConflictDoNothing(); +}; diff --git a/apps/indexer/src/index.ts b/apps/indexer/src/index.ts index bc28059c2..84c24c815 100644 --- a/apps/indexer/src/index.ts +++ b/apps/indexer/src/index.ts @@ -35,6 +35,7 @@ import { CONTRACT_ADDRESSES } from "@/lib/constants"; import { DaoIdEnum } from "@/lib/enums"; import { SHUGovernorIndexer, SHUTokenIndexer } from "./indexer/shu"; +import { TORNTokenIndexer, TORNGovernorIndexer } from "./indexer/torn"; import { AAVETokenIndexer, stkAAVETokenIndexer, @@ -120,6 +121,11 @@ switch (daoId) { FLUIDGovernorIndexer(blockTime); break; } + case DaoIdEnum.TORN: { + TORNTokenIndexer(token.address, token.decimals); + TORNGovernorIndexer(blockTime); + break; + } case DaoIdEnum.AAVE: { const { aave, stkAAVE, aAAVE } = CONTRACT_ADDRESSES[DaoIdEnum.AAVE]; AAVETokenIndexer(aave.address, aave.decimals); diff --git a/apps/indexer/src/indexer/torn/INTEGRATION.md b/apps/indexer/src/indexer/torn/INTEGRATION.md new file mode 100644 index 000000000..dde54f19d --- /dev/null +++ b/apps/indexer/src/indexer/torn/INTEGRATION.md @@ -0,0 +1,148 @@ +# Tornado Cash (TORN) Integration Status + +## Architecture + +| Contract | Address | Type | Events used | +| --------------------- | ------------------------------------------ | --------------------- | ---------------------------------------------------------------- | +| TORN Token | 0x77777FeDdddFfC19Ff86DB637967013e6C6A116C | ERC20 (no delegation) | Transfer | +| Governance | 0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce | Custom stake-to-vote | ProposalCreated, Voted, ProposalExecuted, Delegated, Undelegated | +| TornadoStakingRewards | 0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29 | Staking rewards | (not indexed) | +| TornadoVault | 0x2F50508a8a3D323B91336FA3eA6ae50E55f32185 | Vault | (not indexed) | + +Governor voting token: Voting power comes from `lockedBalance` in the Governance contract, NOT from token-level delegation. Users lock TORN into the Governance contract to gain voting power. + +## What's Integrated + +- [x] Token supply tracking (Transfer events) +- [x] CEX/DEX/Lending/Treasury/NonCirculating supply classification +- [x] Circulating supply calculation +- [x] Delegated supply tracking (via lock/unlock detection — Transfer to/from Governance contract) +- [x] Governor-level delegation tracking (Delegated/Undelegated events) +- [x] Governor proposals (ProposalCreated — custom handler with timestamp-based timing) +- [x] Governor votes (Voted — binary for/against, no abstain) +- [x] Proposal execution (ProposalExecuted event) +- [x] API client with timestamp-based proposal status computation + +## What's Pending + +### Per-account voting power — ADDRESSED (requires reindex) + +Voting power = `lockedBalance` in the governor; TORN emits no `DelegateVotesChanged`. +It is now derived in `erc20.ts` from lock/unlock token Transfers: TORN moving **into** +governance custody (governor pre-v2, **TornadoVault** post-v2) is a lock crediting the +sender's voting power; moving **out** is an unlock debiting the receiver. This populates +`accountPower.votingPower` + `votingPowerHistory` via `lockedVotingPowerChanged` +(`eventHandlers/delegation.ts`) and also fixes `delegatedSupply` to include the Vault +(previously governor-only — it missed ~2.6M TORN held in the Vault). + +- **Requires a full reindex** to take effect. +- **Verify after reindex** with the reconciliation script (`delegatedSupply` ≈ Σ `lockedBalance`; + sampled `accountPower.votingPower` == on-chain `lockedBalance(account)`). +- **Future refinement (deferred):** the exact source of truth is `lockedBalance(account)` read on + `RewardUpdateSuccessful`/`RewardUpdateFailed`. The Transfer-derived value matches it for normal + lock/unlock; switch to event+`readContract` only if a discrepancy is found. + +### Re-vote vote-tally drift — DEFERRED (needs indexer test) + +`_castVote` lets a voter overwrite a prior vote, but the `Voted` handler uses +`onConflictDoNothing` on `(voter, proposalId)` (to avoid Ponder's batch-flush +`DelayedInsertError`), so a genuine re-vote is dropped and tallies can drift from chain. +A correct fix must reverse the old receipt and apply the new one **while staying +idempotent on replay** (e.g. dedupe on `(txHash, logIndex)`), which needs an indexer +backfill run to confirm it doesn't re-introduce the crash. Re-votes appear infrequent; +deferred until it can be tested. (Also flagged in review on #2002.) + +### No abstain votes + +Tornado Cash uses binary voting (`bool support`: true=for, false=against). The `abstainVotes` field on proposals will always be 0. Dashboard should ideally hide the abstain column for TORN. + +### Vote extension mechanism not tracked + +If a vote outcome flips during the last hour (CLOSING_PERIOD = 3600s), voting extends by 6 hours (VOTE_EXTEND_TIME = 21600s). Our indexer stores the initial `endTime` from `ProposalCreated`. The actual end time may differ for proposals where the extension triggered. This could cause brief status mismatches during the extension window. No on-chain event is emitted for the extension. + +### No intermediate proposal state events + +Tornado Cash does NOT emit events for state transitions between Pending, Active, Defeated, Timelocked, AwaitingExecution, and Expired. Only `ProposalCreated` and `ProposalExecuted` are emitted. All intermediate states are computed at the API level by `TORNClient.getProposalStatus()` using timestamp comparisons against governance parameters. + +### Staking rewards not tracked + +The `TornadoStakingRewards` contract (0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29) distributes relayer fees to locked TORN holders. This economic dimension is not captured. It doesn't affect governance voting directly but represents additional yield for participants. + +### Treasury accounting — VERIFY (deferred) + +`TreasuryAddresses[TORN]` is currently empty, so the indexed `treasury` supply metric is 0. +Tornado's treasury is non-trivial: the governance contract holds ~4.73M TORN that **commingles** +DAO-owned treasury with pre-v2 user locks, so its balance is *not* a clean treasury figure +(adding the governor to `TreasuryAddresses` would overcount by the locked amount). The real +treasury ≈ governance-owned TORN that does not back any account's `lockedBalance`, plus ETH. + +- **Before relying on attack-profitability:** confirm whether that view uses the on-chain + *liquid treasury call* (`attackProfitability.supportsLiquidTreasuryCall: true`) or the indexed + `treasury` metric. If the latter, treasury will read 0 until this is resolved. +- A correct figure likely needs a dedicated computation (governance TORN balance − Σ `lockedBalance` + custodied in the governor, + ETH/other assets). Deferred as a TORN-specific specificity. + +### Proposal target decoding + +Proposals are deployed contracts executed via `delegatecall` from the Governance contract. We index the `target` address but cannot decode what the proposal does without reading the target contract's bytecode. `alreadySupportCalldataReview` is set to `false`. + +### Cancel function + +Tornado Cash proposals cannot be canceled once created. There is no `ProposalCanceled` event. The `cancelFunction` rule is set to `false`. + +## Notes + +### Custom governance architecture + +Tornado Cash uses a LoopbackProxy (TransparentUpgradeableProxy where the proxy is its own admin). The governance contract has been upgraded multiple times; current version is `"5.proposal-state-patch"`. + +Key architectural differences from standard OZ/Compound governors: + +- **Stake-to-vote**: Lock TORN → get voting power (no token-level delegation) +- **Timestamp-based**: All timing parameters (VOTING_DELAY, VOTING_PERIOD, EXECUTION_DELAY, EXECUTION_EXPIRATION) are in seconds +- **Binary voting**: for/against only, no abstain +- **Built-in timelock**: No separate timelock contract +- **Proposal = contract**: Proposals are deployed contracts with `executeProposal()`, executed via `delegatecall` + +### Governance attack (May 2023) + +In May 2023, an attacker gained majority voting power via a malicious proposal (proposal #21) that granted them TORN tokens within the governance contract. The attack was later partially reversed. The indexed data accurately reflects this period, which may show unusual voting power concentration on the dashboard. + +### OFAC sanctions + +Tornado Cash was sanctioned by the U.S. Treasury's OFAC in August 2022. While the sanctions on the smart contracts were later vacated by a federal court ruling (November 2024), some RPC providers (Merkle) still block calls to Tornado Cash contracts. The indexer and API must use a non-censoring RPC provider. + +### Governance parameters (on-chain values) + +| Parameter | Value | Human-readable | +| -------------------- | ------ | -------------- | +| VOTING_DELAY | 75 | 75 seconds | +| VOTING_PERIOD | 432000 | 5 days | +| QUORUM_VOTES | 1e23 | 100,000 TORN | +| PROPOSAL_THRESHOLD | 1e21 | 1,000 TORN | +| EXECUTION_DELAY | 172800 | 2 days | +| EXECUTION_EXPIRATION | 259200 | 3 days | +| CLOSING_PERIOD | 3600 | 1 hour | +| VOTE_EXTEND_TIME | 21600 | 6 hours | + +### Vote handler: onConflictDoNothing + +The shared `voteCast()` handler uses plain inserts which trigger Ponder's `DelayedInsertError` during batch flushing (duplicate key constraint on `votes_onchain`). This is specific to Tornado Cash and occurs during backfill. The custom handler uses `onConflictDoNothing` on the vote insert to prevent crashes. The insert returns `null` on conflict, and the handler short-circuits in that case so tally/count/feed updates only run for genuinely new votes — no double-counting on replay. + +### Vote tallies vs on-chain proposals struct + +Indexed vote tallies (from Voted events) may differ from on-chain `proposals(id).forVotes/againstVotes`. The governance contract was upgraded 5 times (current version: `"5.proposal-state-patch"`), including post-attack recovery proposals that may have directly modified the `proposals` mapping. Our indexed data reflects the immutable Voted event history. Both are valid — events show what happened, on-chain struct shows the current governance state. + +### Verification results (March 2026) + +Full backfill completed successfully with zero crashes. + +| Metric | Indexed | On-chain | Status | +| ------------------ | ------------ | ------------ | --------------- | +| Proposals | 65 | 65 | Exact match | +| Executed proposals | 49 | 49 | Exact match | +| Votes | 1,089 | — | Zero duplicates | +| delegatedSupply | 4,732,271.91 | 4,732,271.91 | Exact match | +| Delegations | 72 | — | — | +| Transfers | 458,282 | — | — | +| Accounts | 42,890 | — | — | diff --git a/apps/indexer/src/indexer/torn/RESEARCH.md b/apps/indexer/src/indexer/torn/RESEARCH.md new file mode 100644 index 000000000..c8fab7e4e --- /dev/null +++ b/apps/indexer/src/indexer/torn/RESEARCH.md @@ -0,0 +1,205 @@ +# 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** +(attacker target `0x5efda50f22d34f272c7077689d6abc42f15e285f` vs the real governor +`0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce`) to swap governance control over a ~$23M TORN treasury. +This is a **vanity-prefix spoof**, not a one-character typo: the two addresses share the first **15 hex +characters** and only diverge at the 16th, so the detector in §5 must match **shared leading prefixes** +(vanity-address collisions), not single-character edits. 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 TORN Transfers in/out of governor only (erc20.ts:150-159) + • TODO: also watch TornadoVault transfers — post-v2 locks route there (§4 gap #6) + ▼ 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 **(as of March 2026)**: 65 proposals, 49 executed, 1,089 votes — matched chain. + ⚠️ Proposals 66–67 have since landed (incl. the **June 2026** attack) — re-run/verify backfill to cover them. + +--- + +## 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. +6. **Lock custody coverage** — the indexer detects locks/unlocks via TORN transfers to/from the **governor** + only (`erc20.ts:150-159`). Post-v2, `lock` routes TORN to the **TornadoVault** (`0x2F50…`, per + `GovernanceVaultUpgrade._transferTokens`), so vault-custodied locks/unlocks can be missed and + `delegatedSupply`/account power may drift. (The March-2026 exact match was against the governor balance.) + Watch governor **and** Vault transfers. + +--- + +## 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) +- 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 +- Trigger a "balance-changed" snapshot on **both** `RewardUpdateSuccessful(account)` **and** + `RewardUpdateFailed(account, errorData)`. The governor's `updateRewards` modifier + (`GovernanceStakingUpgrade.sol`) wraps the reward sync in try/catch and emits one or the other, but the + lock/unlock runs regardless — so `lockedBalance` changes even on the *failed* path. Watching only the + success event would miss those balance changes. +- On either event, `readContract lockedBalance(account)` and write `votingPowerHistory` + + `accountPower.votingPower`. +- Belt-and-suspenders: reconcile against TORN `Transfer` to/from governor **+ Vault** (gap #6), since + `RewardUpdate*` only exists from the v3 staking upgrade onward — pre-v3 history must come from transfers. + +### 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 | diff --git a/apps/indexer/src/indexer/torn/abi/governor.ts b/apps/indexer/src/indexer/torn/abi/governor.ts new file mode 100644 index 000000000..267a63096 --- /dev/null +++ b/apps/indexer/src/indexer/torn/abi/governor.ts @@ -0,0 +1,45 @@ +export const TORNGovernorAbi = [ + { + type: "event", + name: "ProposalCreated", + inputs: [ + { indexed: true, name: "id", type: "uint256" }, + { indexed: true, name: "proposer", type: "address" }, + { indexed: false, name: "target", type: "address" }, + { indexed: false, name: "startTime", type: "uint256" }, + { indexed: false, name: "endTime", type: "uint256" }, + { indexed: false, name: "description", type: "string" }, + ], + }, + { + type: "event", + name: "Voted", + inputs: [ + { indexed: true, name: "proposalId", type: "uint256" }, + { indexed: true, name: "voter", type: "address" }, + { indexed: true, name: "support", type: "bool" }, + { indexed: false, name: "votes", type: "uint256" }, + ], + }, + { + type: "event", + name: "ProposalExecuted", + inputs: [{ indexed: true, name: "proposalId", type: "uint256" }], + }, + { + type: "event", + name: "Delegated", + inputs: [ + { indexed: true, name: "account", type: "address" }, + { indexed: true, name: "to", type: "address" }, + ], + }, + { + type: "event", + name: "Undelegated", + inputs: [ + { indexed: true, name: "account", type: "address" }, + { indexed: true, name: "from", type: "address" }, + ], + }, +] as const; diff --git a/apps/indexer/src/indexer/torn/abi/index.ts b/apps/indexer/src/indexer/torn/abi/index.ts new file mode 100644 index 000000000..bdfdcc16d --- /dev/null +++ b/apps/indexer/src/indexer/torn/abi/index.ts @@ -0,0 +1,2 @@ +export { TORNTokenAbi } from "./token"; +export { TORNGovernorAbi } from "./governor"; diff --git a/apps/indexer/src/indexer/torn/abi/token.ts b/apps/indexer/src/indexer/torn/abi/token.ts new file mode 100644 index 000000000..eca708a0a --- /dev/null +++ b/apps/indexer/src/indexer/torn/abi/token.ts @@ -0,0 +1,12 @@ +// Standard ERC20 — Transfer only (TORN has no delegation events) +export const TORNTokenAbi = [ + { + type: "event", + name: "Transfer", + inputs: [ + { indexed: true, name: "from", type: "address" }, + { indexed: true, name: "to", type: "address" }, + { indexed: false, name: "value", type: "uint256" }, + ], + }, +] as const; diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts new file mode 100644 index 000000000..26d11df8f --- /dev/null +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -0,0 +1,191 @@ +import { ponder } from "ponder:registry"; +import { token } from "ponder:schema"; +import { Address, getAddress } from "viem"; + +import { tokenTransfer, lockedVotingPowerChanged } from "@/eventHandlers"; +import { + updateDelegatedSupply, + updateCirculatingSupply, + updateSupplyMetric, + updateTotalSupply, +} from "@/eventHandlers/metrics"; +import { + CONTRACT_ADDRESSES, + MetricTypesEnum, + BurningAddresses, + CEXAddresses, + DEXAddresses, + LendingAddresses, + TreasuryAddresses, + NonCirculatingAddresses, +} from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +export function TORNTokenIndexer(address: Address, decimals: number) { + const daoId = DaoIdEnum.TORN; + const governorAddress = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].governor.address, + ); + // Post-v2, locked TORN is custodied in the TornadoVault, not the governor + // (GovernanceVaultUpgrade._transferTokens). Both are lock "sinks": a Transfer + // into either is a lock; out of either is an unlock. Pre-v2 used the governor. + // https://etherscan.io/address/0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185 + const vaultAddress = getAddress("0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185"); + + ponder.on("TORNToken:setup", async ({ context }) => { + await context.db.insert(token).values({ + id: address, + name: daoId, + decimals, + }); + }); + + ponder.on("TORNToken:Transfer", async ({ event, context }) => { + const { from, to, value } = event.args; + const { timestamp } = event.block; + + const cexAddressList = Object.values(CEXAddresses[daoId]); + const dexAddressList = Object.values(DEXAddresses[daoId]); + const lendingAddressList = Object.values(LendingAddresses[daoId]); + const burningAddressList = Object.values(BurningAddresses[daoId]); + const treasuryAddressList = Object.values(TreasuryAddresses[daoId]); + const nonCirculatingAddressList = Object.values( + NonCirculatingAddresses[daoId], + ); + + await tokenTransfer( + context, + daoId, + { + from, + to, + value, + token: address, + transactionHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }, + { + cex: cexAddressList, + dex: dexAddressList, + lending: lendingAddressList, + burning: burningAddressList, + }, + ); + + await updateSupplyMetric( + context, + "lendingSupply", + lendingAddressList, + MetricTypesEnum.LENDING_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateSupplyMetric( + context, + "cexSupply", + cexAddressList, + MetricTypesEnum.CEX_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateSupplyMetric( + context, + "dexSupply", + dexAddressList, + MetricTypesEnum.DEX_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateSupplyMetric( + context, + "treasury", + treasuryAddressList, + MetricTypesEnum.TREASURY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateSupplyMetric( + context, + "nonCirculatingSupply", + nonCirculatingAddressList, + MetricTypesEnum.NON_CIRCULATING_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateTotalSupply( + context, + burningAddressList, + MetricTypesEnum.TOTAL_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + + await updateCirculatingSupply(context, daoId, address, timestamp); + + // Track locks/unlocks: TORN moving in/out of governance custody (the + // governor pre-v2, the TornadoVault post-v2). The non-custody side is the + // user whose lockedBalance (voting power) changes. We update both the + // aggregate delegatedSupply and the per-account voting power. + const normalizedTo = getAddress(to); + const normalizedFrom = getAddress(from); + const toIsSink = + normalizedTo === governorAddress || normalizedTo === vaultAddress; + const fromIsSink = + normalizedFrom === governorAddress || normalizedFrom === vaultAddress; + + // Lock: tokens move INTO custody from a user (ignore internal + // governor<->vault moves such as the v2 migration). + if (toIsSink && !fromIsSink) { + await updateDelegatedSupply(context, daoId, address, value, timestamp); + await lockedVotingPowerChanged(context, daoId, { + account: from, + delta: value, + txHash: event.transaction.hash, + timestamp, + logIndex: event.log.logIndex, + }); + } + + // Unlock: tokens move OUT of custody to a user. + if (fromIsSink && !toIsSink) { + await updateDelegatedSupply(context, daoId, address, -value, timestamp); + await lockedVotingPowerChanged(context, daoId, { + account: to, + delta: -value, + txHash: event.transaction.hash, + timestamp, + logIndex: event.log.logIndex, + }); + } + }); +} diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts new file mode 100644 index 000000000..281784cbe --- /dev/null +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -0,0 +1,271 @@ +import { ponder } from "ponder:registry"; +import { + accountBalance, + accountPower, + feedEvent, + proposalsOnchain, + votesOnchain, +} from "ponder:schema"; +import { getAddress } from "viem"; + +import { delegateChanged, updateProposalStatus } from "@/eventHandlers"; +import { ensureAccountExists } from "@/eventHandlers/shared"; +import { CONTRACT_ADDRESSES, ProposalStatus } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +const MAX_TITLE_LENGTH = 200; + +/** + * Extracts a proposal title from a markdown description. + * + * Strategy: + * 1. Normalize literal `\n` sequences to real newlines (some proposers + * submit descriptions with escaped newlines). + * 2. If the first non-empty line is an H1 (`# Title`), use it. + * 3. Otherwise, use the first non-empty line that is not a section header + * (H2+), truncated to MAX_TITLE_LENGTH characters. + */ +function parseProposalTitle(description: string): string { + // Try JSON first — some Tornado proposals use {"title":"...","description":"..."} + try { + const parsed = JSON.parse(description) as { + title?: string; + description?: string; + }; + if (parsed.title) return parsed.title; + } catch { + // Not JSON, continue with markdown parsing + } + + // Normalize literal "\n" (two chars) into real newlines + const normalized = description.replace(/\\n/g, "\n"); + const lines = normalized.split("\n"); + + // Pass 1: look for an H1 among leading lines (before any content) + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^# /.test(trimmed)) { + return trimmed.replace(/^# +/, ""); + } + break; // stop at first non-empty, non-H1 line + } + + // Pass 2: no H1 found — use first non-empty, non-header line + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || /^#{1,6}\s/.test(trimmed)) continue; + return trimmed.length > MAX_TITLE_LENGTH + ? trimmed.substring(0, MAX_TITLE_LENGTH) + "..." + : trimmed; + } + + return ""; +} + +/** + * Custom governance indexer for Tornado Cash DAO. + * + * Key differences from standard governors: + * - ProposalCreated uses timestamps (startTime/endTime) instead of block numbers + * - Voted event uses bool support instead of uint8 + * - Delegation happens through the governance contract (Delegated/Undelegated events) + */ +export function TORNGovernorIndexer(blockTime: number) { + const daoId = DaoIdEnum.TORN; + const TORN_TOKEN_ADDRESS = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].token.address, + ); + + ponder.on("TORNGovernor:ProposalCreated", async ({ event, context }) => { + const { id, proposer, target, startTime, endTime, description } = + event.args; + const proposalIdStr = id.toString(); + + await ensureAccountExists(context, proposer); + + const title = parseProposalTitle(description); + + // Convert timestamps to synthetic block numbers for schema compat + const startBlock = + Number(event.block.number) + + Math.floor( + (Number(startTime) - Number(event.block.timestamp)) / blockTime, + ); + const endBlock = + Number(event.block.number) + + Math.floor((Number(endTime) - Number(event.block.timestamp)) / blockTime); + + await context.db.insert(proposalsOnchain).values({ + id: proposalIdStr, + txHash: event.transaction.hash, + daoId, + proposerAccountId: getAddress(proposer), + targets: [getAddress(target)], + values: [], + signatures: [], + calldatas: [], + startBlock, + endBlock, + title, + description, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + // Non-zero voting delay means a proposal can be created before its + // voting window opens — persist PENDING so status-filtered queries + // don't miss it during the pre-vote window. + status: + event.block.timestamp < startTime + ? ProposalStatus.PENDING + : ProposalStatus.ACTIVE, + endTimestamp: endTime, + }); + + const { votingPower: proposerVotingPower } = await context.db + .insert(accountPower) + .values({ + accountId: getAddress(proposer), + daoId, + proposalsCount: 1, + }) + .onConflictDoUpdate((current) => ({ + proposalsCount: current.proposalsCount + 1, + })); + + await context.db.insert(feedEvent).values({ + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + type: "PROPOSAL", + timestamp: event.block.timestamp, + metadata: { + id: proposalIdStr, + proposer: getAddress(proposer), + votingPower: proposerVotingPower, + title, + }, + }); + }); + + /** + * Voted — custom handler using onConflictDoNothing to prevent Ponder's + * DelayedInsertError crash during batch flushing. Ponder's deferred insert + * mechanism can hit unique constraint violations during backfill; plain + * inserts (as in the shared voteCast) cause unhandledRejection crashes. + * + * bool support mapped: true→1 (for), false→0 (against). No reason field. + */ + ponder.on("TORNGovernor:Voted", async ({ event, context }) => { + const { proposalId, voter, support, votes } = event.args; + const proposalIdStr = proposalId.toString(); + const supportNum = support ? 1 : 0; + const normalizedVoter = getAddress(voter); + + await ensureAccountExists(context, voter); + + // onConflictDoNothing prevents DelayedInsertError from Ponder's batch flush. + // Returns null on conflict (duplicate vote during replay/backfill) — in that + // case skip all downstream updates so tallies/counts aren't double-counted. + const inserted = await context.db + .insert(votesOnchain) + .values({ + txHash: event.transaction.hash, + daoId, + proposalId: proposalIdStr, + voterAccountId: normalizedVoter, + support: supportNum.toString(), + votingPower: votes, + reason: "", + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }) + .onConflictDoNothing(); + + if (!inserted) return; + + await context.db + .insert(accountPower) + .values({ + accountId: normalizedVoter, + daoId, + votesCount: 1, + lastVoteTimestamp: event.block.timestamp, + }) + .onConflictDoUpdate((current) => ({ + votesCount: current.votesCount + 1, + lastVoteTimestamp: event.block.timestamp, + })); + + await context.db + .update(proposalsOnchain, { id: proposalIdStr }) + .set((current) => ({ + againstVotes: current.againstVotes + (supportNum === 0 ? votes : 0n), + forVotes: current.forVotes + (supportNum === 1 ? votes : 0n), + })); + + const proposal = await context.db.find(proposalsOnchain, { + id: proposalIdStr, + }); + + await context.db.insert(feedEvent).values({ + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + type: "VOTE", + value: votes, + timestamp: event.block.timestamp, + metadata: { + voter: normalizedVoter, + reason: "", + support: supportNum, + votingPower: votes, + proposalId: proposalIdStr, + title: proposal?.title ?? undefined, + }, + }); + }); + + ponder.on("TORNGovernor:ProposalExecuted", async ({ event, context }) => { + await updateProposalStatus( + context, + event.args.proposalId.toString(), + ProposalStatus.EXECUTED, + event.block.timestamp, + event.transaction.hash, + ); + }); + + ponder.on("TORNGovernor:Delegated", async ({ event, context }) => { + const { account, to } = event.args; + + // Look up the previous delegate from accountBalance + const existing = await context.db.find(accountBalance, { + accountId: getAddress(account), + tokenId: TORN_TOKEN_ADDRESS, + }); + const previousDelegate = existing?.delegate ?? getAddress(account); + + await delegateChanged(context, daoId, { + delegator: account, + delegate: to, + tokenId: TORN_TOKEN_ADDRESS, + previousDelegate, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); + + ponder.on("TORNGovernor:Undelegated", async ({ event, context }) => { + const { account, from } = event.args; + + // Undelegation: delegate reverts to self, previous delegate was `from` + await delegateChanged(context, daoId, { + delegator: account, + delegate: account, + tokenId: TORN_TOKEN_ADDRESS, + previousDelegate: from, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); +} diff --git a/apps/indexer/src/indexer/torn/index.ts b/apps/indexer/src/indexer/torn/index.ts new file mode 100644 index 000000000..c2689388e --- /dev/null +++ b/apps/indexer/src/indexer/torn/index.ts @@ -0,0 +1,3 @@ +export { TORNTokenAbi, TORNGovernorAbi } from "./abi"; +export { TORNTokenIndexer } from "./erc20"; +export { TORNGovernorIndexer } from "./governor"; diff --git a/apps/indexer/src/lib/constants.ts b/apps/indexer/src/lib/constants.ts index 0562ff8fa..55f51d0eb 100644 --- a/apps/indexer/src/lib/constants.ts +++ b/apps/indexer/src/lib/constants.ts @@ -230,6 +230,20 @@ export const CONTRACT_ADDRESSES = { address: "0xA700b4eB416Be35b2911fd5Dee80678ff64fF6C9", }, }, + [DaoIdEnum.TORN]: { + blockTime: 12, + // https://etherscan.io/address/0x77777FeDdddFfC19Ff86DB637967013e6C6A116C + token: { + address: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + decimals: 18, + startBlock: 11474599, + }, + // https://etherscan.io/address/0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce + governor: { + address: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + startBlock: 11474695, + }, + }, } as const; export const TreasuryAddresses: Record> = { @@ -403,6 +417,7 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + [DaoIdEnum.TORN]: {}, }; export const CEXAddresses: Record> = { @@ -622,6 +637,7 @@ export const CEXAddresses: Record> = { Gate: "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", Bitvavo: "0xaB782bc7D4a2b306825de5a7730034F8F63ee1bC", }, + [DaoIdEnum.TORN]: {}, }; export const DEXAddresses: Record> = { @@ -695,6 +711,7 @@ export const DEXAddresses: Record> = { [DaoIdEnum.FLUID]: { "Uniswap V3 INST/WETH": "0xc1cd3D0913f4633b43FcdDBCd7342bC9b71C676f", }, + [DaoIdEnum.TORN]: {}, }; export const LendingAddresses: Record> = { @@ -745,6 +762,7 @@ export const LendingAddresses: Record> = { }, [DaoIdEnum.SHU]: {}, [DaoIdEnum.FLUID]: {}, + [DaoIdEnum.TORN]: {}, }; export const BurningAddresses: Record< @@ -832,6 +850,11 @@ export const BurningAddresses: Record< Dead: "0x000000000000000000000000000000000000dEaD", TokenContract: "0x6f40d4A6237C257fff2dB00FA0510DeEECd303eb", }, + [DaoIdEnum.TORN]: { + ZeroAddress: zeroAddress, + Dead: "0x000000000000000000000000000000000000dEaD", + TokenContract: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + }, }; export const NonCirculatingAddresses: Record< @@ -872,6 +895,10 @@ export const NonCirculatingAddresses: Record< }, [DaoIdEnum.LIL_NOUNS]: {}, [DaoIdEnum.SHU]: {}, + [DaoIdEnum.TORN]: { + governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", + }, }; export enum ProposalStatus { diff --git a/apps/indexer/src/lib/enums.ts b/apps/indexer/src/lib/enums.ts index 3580fd6f3..c558794b7 100644 --- a/apps/indexer/src/lib/enums.ts +++ b/apps/indexer/src/lib/enums.ts @@ -14,6 +14,7 @@ export enum DaoIdEnum { SHU = "SHU", FLUID = "FLUID", LIL_NOUNS = "LIL_NOUNS", + TORN = "TORN", } export const SECONDS_IN_DAY = 24 * 60 * 60; diff --git a/scripts/dev.sh b/scripts/dev.sh index 13b139357..a2556ed29 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -31,6 +31,7 @@ dao_id_for() { uniswap) echo "uni" ;; gitcoin) echo "gtc" ;; scroll) echo "scr" ;; + tornado) echo "torn" ;; shutter) echo "shu" ;; compound) echo "comp" ;; *) echo "$1" ;; @@ -132,19 +133,9 @@ start_gateful() { } start_relayer() { - if [ -z "$DAO_NAME" ]; then - log "Skipping optional Relayer (no DAO selected)" - return 1 - fi - local service="$(echo "$DAO_NAME" | tr '[:upper:]' '[:lower:]')-relayer" - log "Starting optional Relayer ($service)..." - # Always run through `railway run`; if the service doesn't exist it simply - # fails to come up and wait_for_optional_port lets the stack continue. - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s "$service" pnpm relayer dev & - if wait_for_optional_port "$PORT_RELAYER" "Relayer"; then - return 0 - fi - return 1 + export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & + wait_for_port "$PORT_RELAYER" "Relayer" } if [ "${BASH_SOURCE[0]}" != "$0" ]; then @@ -267,11 +258,8 @@ if [ "$RUN_API" = true ]; then ) & fi -# 5. Relayer (optional; only available for a few DAOs — do not block the rest of the stack) -RELAYER_AVAILABLE=false -if start_relayer; then - RELAYER_AVAILABLE=true -fi +# 5. Relayer (only available for ENS) +start_relayer # 6. Gateful start_gateful @@ -299,11 +287,7 @@ else printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" fi printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" -if [ "$RELAYER_AVAILABLE" = true ]; then - printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" -else - printf " ${C_RELAYER}📡 Relayer${C_RESET} skipped (optional)\n" -fi +printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" echo "" diff --git a/scripts/torn-reconcile.ts b/scripts/torn-reconcile.ts new file mode 100644 index 000000000..0469544a4 --- /dev/null +++ b/scripts/torn-reconcile.ts @@ -0,0 +1,434 @@ +#!/usr/bin/env -S npx tsx +/* eslint-disable @typescript-eslint/no-explicit-any -- standalone ops/verification script: viem dynamic contract reads and tolerant API JSON parsing intentionally use `any`. */ +/** + * TORN integration reconciliation / smoke test + * -------------------------------------------- + * Validates every datapoint the Anticapture TORN integration surfaces against + * on-chain ground truth. Produces a PASS/FAIL/SKIP report and a non-zero exit + * code on any FAIL — use it as a production gate and as the verification for the + * indexer Tier-1/Tier-2 fixes. + * + * Modes: + * - on-chain only (default): asserts the model invariants the integration + * assumes (params, quorum semantics, custody). Runs with no API. + * - full (set API_BASE): also fetches each TORN endpoint and diffs it against + * the on-chain reads. + * + * Run: + * RPC_URL=https://ethereum-rpc.publicnode.com npx tsx torn-reconcile.ts + * API_BASE=https://api.anticapture.com RPC_URL=... npx tsx torn-reconcile.ts + * + * Notes: + * - Use a NON-CENSORING RPC. Several public RPCs block Tornado Cash calls + * (OFAC). publicnode worked at time of writing; llamarpc/cloudflare/ankr did not. + * - API field paths are best-effort; confirm against the generated OpenAPI/client. + */ +import { createPublicClient, http, getAddress, formatUnits } from "viem"; +import { mainnet } from "viem/chains"; + +const RPC_URL = process.env.RPC_URL ?? "https://ethereum-rpc.publicnode.com"; +const API_BASE = process.env.API_BASE ?? ""; // empty => on-chain-only mode +const DAO = "TORN"; + +const GOV = getAddress("0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce"); +const TORN = getAddress("0x77777FeDdddFfC19Ff86DB637967013e6C6A116C"); +const VAULT = getAddress("0x2F50508a8a3D323B91336FA3eA6Ae50e55f32185"); + +// --- validated ABI fragments (selectors confirmed against mainnet) --- +const govAbi = [ + { + type: "function", + name: "proposalCount", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "QUORUM_VOTES", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "PROPOSAL_THRESHOLD", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "VOTING_DELAY", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "VOTING_PERIOD", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "EXECUTION_DELAY", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "EXECUTION_EXPIRATION", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "state", + stateMutability: "view", + inputs: [{ type: "uint256" }], + outputs: [{ type: "uint8" }], + }, + { + type: "function", + name: "lockedBalance", + stateMutability: "view", + inputs: [{ type: "address" }], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "delegatedTo", + stateMutability: "view", + inputs: [{ type: "address" }], + outputs: [{ type: "address" }], + }, + { + type: "function", + name: "proposals", + stateMutability: "view", + inputs: [{ type: "uint256" }], + outputs: [ + { name: "proposer", type: "address" }, + { name: "target", type: "address" }, + { name: "startTime", type: "uint256" }, + { name: "endTime", type: "uint256" }, + { name: "forVotes", type: "uint256" }, + { name: "againstVotes", type: "uint256" }, + { name: "executed", type: "bool" }, + { name: "extended", type: "bool" }, + ], + }, +] as const; +const erc20Abi = [ + { + type: "function", + name: "totalSupply", + stateMutability: "view", + inputs: [], + outputs: [{ type: "uint256" }], + }, + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ type: "address" }], + outputs: [{ type: "uint256" }], + }, +] as const; + +// TORN state() enum order (from Governance.sol) +const STATE = [ + "Pending", + "Active", + "Defeated", + "Timelocked", + "AwaitingExecution", + "Executed", + "Expired", +]; + +// viem returns multi-output functions as a positional array — normalize to an object +function asProposal(p: any) { + return Array.isArray(p) + ? { + proposer: p[0], + target: p[1], + startTime: p[2], + endTime: p[3], + forVotes: p[4], + againstVotes: p[5], + executed: p[6], + extended: p[7], + } + : p; +} + +const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) }); +const rGov = (functionName: any, args?: any[]) => + client.readContract({ address: GOV, abi: govAbi, functionName, args }); +const rTok = (functionName: any, args?: any[]) => + client.readContract({ address: TORN, abi: erc20Abi, functionName, args }); + +type Row = { + check: string; + onchain: string; + api: string; + status: "PASS" | "FAIL" | "SKIP"; +}; +const rows: Row[] = []; +const add = (check: string, onchain: any, api: any, status: Row["status"]) => + rows.push({ check, onchain: String(onchain), api: String(api), status }); + +async function apiGet(path: string): Promise { + if (!API_BASE) return null; + try { + const url = `${API_BASE}${path}${path.includes("?") ? "&" : "?"}dao=${DAO}`; + const res = await fetch(url); + if (!res.ok) return { __error: `HTTP ${res.status}` }; + return await res.json(); + } catch (e: any) { + return { __error: e.message }; + } +} + +async function main() { + console.log( + `\nTORN reconciliation — RPC=${RPC_URL} API=${API_BASE || "(on-chain only)"}\n`, + ); + + // 1. Governance params (validate config + API /dao) + const [count, quorum, threshold, vDelay, vPeriod, exDelay, exExp] = + (await Promise.all([ + rGov("proposalCount"), + rGov("QUORUM_VOTES"), + rGov("PROPOSAL_THRESHOLD"), + rGov("VOTING_DELAY"), + rGov("VOTING_PERIOD"), + rGov("EXECUTION_DELAY"), + rGov("EXECUTION_EXPIRATION"), + ])) as bigint[]; + + // model invariants (these are the assumptions the integration is built on) + add( + "invariant: quorum = 100,000 TORN", + formatUnits(quorum, 18), + "100000", + quorum === 100000n * 10n ** 18n ? "PASS" : "FAIL", + ); + add( + "invariant: threshold = 1,000 TORN", + formatUnits(threshold, 18), + "1000", + threshold === 1000n * 10n ** 18n ? "PASS" : "FAIL", + ); + add( + "invariant: voting delay = 75s", + vDelay, + "75", + vDelay === 75n ? "PASS" : "FAIL", + ); + add( + "invariant: voting period = 5d", + vPeriod, + "432000", + vPeriod === 432000n ? "PASS" : "FAIL", + ); + add( + "invariant: execution delay = 2d", + exDelay, + "172800", + exDelay === 172800n ? "PASS" : "FAIL", + ); + add( + "invariant: execution expiration = 3d", + exExp, + "259200", + exExp === 259200n ? "PASS" : "FAIL", + ); + + const dao = await apiGet("/dao"); + if (dao && !dao.__error) { + const eq = (a: any, b: bigint) => String(a) === String(b); + add( + "API /dao quorum", + quorum, + dao.quorum, + eq(dao.quorum, quorum) ? "PASS" : "FAIL", + ); + add( + "API /dao proposalThreshold", + threshold, + dao.proposalThreshold, + eq(dao.proposalThreshold, threshold) ? "PASS" : "FAIL", + ); + } else add("API /dao", quorum, dao?.__error ?? "skipped", "SKIP"); + + // 2. Proposal count + const props = await apiGet(`/proposals?limit=1`); + const apiCount = + props && !props.__error + ? (props.totalCount ?? props.total ?? "?") + : (props?.__error ?? "skipped"); + add( + "proposal count", + count, + apiCount, + props && !props.__error + ? String(apiCount) === String(count) + ? "PASS" + : "FAIL" + : "SKIP", + ); + + // 3. Per-proposal: state + tallies + quorum-fix correctness (sample latest 3) + const ids = [count, count - 1n, count - 2n].filter((n) => n > 0n); + for (const id of ids) { + const [pRaw, st] = (await Promise.all([ + rGov("proposals", [id]), + rGov("state", [id]), + ])) as [any, number]; + const p = asProposal(pRaw); + const onState = STATE[st]; + // calculateQuorum FIX validation: quorum reached iff for+against >= QUORUM_VOTES + const reached = p.forVotes + p.againstVotes >= quorum; + const oldReached = p.forVotes >= quorum; // buggy: forVotes only + if (reached !== oldReached) { + add( + `#${id} quorum-fix changes outcome`, + `for+against reached=${reached}`, + `forVotes-only=${oldReached}`, + "PASS", + ); + } + const api = await apiGet(`/proposals/${id}`); + if (api && !api.__error) { + const aFor = api.forVotes, + aAg = api.againstVotes, + aStatus = (api.status ?? "").toUpperCase(); + add( + `#${id} forVotes`, + p.forVotes, + aFor, + String(aFor) === String(p.forVotes) ? "PASS" : "FAIL", + ); + add( + `#${id} againstVotes`, + p.againstVotes, + aAg, + String(aAg) === String(p.againstVotes) ? "PASS" : "FAIL", + ); + add( + `#${id} status (vs on-chain ${onState})`, + onState, + aStatus, + aStatus.includes(onState.toUpperCase()) || onState === "Active" + ? "PASS" + : "FAIL", + ); + // target must be preserved (attack-critical) + const aTarget = (api.targets?.[0] ?? api.target ?? "").toLowerCase(); + add( + `#${id} target preserved`, + p.target.toLowerCase(), + aTarget, + aTarget === p.target.toLowerCase() ? "PASS" : "FAIL", + ); + } else + add( + `#${id} (API)`, + `${onState} for=${p.forVotes}`, + api?.__error ?? "skipped", + "SKIP", + ); + } + + // 4. Token custody / delegatedSupply / gap #6 + const [ts, govBal, vaultBal] = (await Promise.all([ + rTok("totalSupply"), + rTok("balanceOf", [GOV]), + rTok("balanceOf", [VAULT]), + ])) as bigint[]; + add("TORN totalSupply", formatUnits(ts, 18), "—", "PASS"); + add("governor TORN custody", formatUnits(govBal, 18), "—", "PASS"); + // gap #6 gate: the indexer derives locked supply from governor transfers only. + // Vault-custodied locks exist and are NOT watched -> block prod until reconciled. + add( + "gap #6: vault holds untracked TORN", + formatUnits(vaultBal, 18), + "governor-only indexer misses this", + vaultBal > 0n ? "FAIL" : "PASS", + ); + const tok = await apiGet("/token"); + if (tok && !tok.__error) { + const ds = BigInt(tok.delegatedSupply ?? 0); + // Truth = Σ lockedBalance(all accounts) (needs full scan). Bound check: it must + // be > governor balance alone if any vault locks are counted; flag if it tracks + // governor-only (i.e. excludes the vault leg). + const tracksGovernorOnly = ds <= govBal + 10n ** 21n; // within ~1000 TORN of govBal + add( + "delegatedSupply (confirm = Σ lockedBalance)", + `gov=${formatUnits(govBal, 18)} vault=${formatUnits(vaultBal, 18)}`, + formatUnits(ds, 18), + tracksGovernorOnly ? "FAIL" : "PASS", + ); + } else + add( + "delegatedSupply (needs API + Σ lockedBalance)", + `gov=${formatUnits(govBal, 18)} vault=${formatUnits(vaultBal, 18)}`, + tok?.__error ?? "skipped", + "SKIP", + ); + + // 5. Per-account voting power (sample = proposer of latest proposal) + const sampleP = asProposal(await rGov("proposals", [count])); + const lb = (await rGov("lockedBalance", [sampleP.proposer])) as bigint; + add( + `lockedBalance(proposer #${count})`, + formatUnits(lb, 18), + ">= 1000 (threshold)", + lb >= threshold ? "PASS" : "FAIL", + ); + const vp = await apiGet(`/voting-powers/${sampleP.proposer}`); + if (vp && !vp.__error) { + add( + `API voting-power(${sampleP.proposer.slice(0, 8)})`, + formatUnits(lb, 18), + vp.votingPower, + String(vp.votingPower) === String(lb) ? "PASS" : "FAIL", + ); + } else + add( + "API voting-power", + formatUnits(lb, 18), + vp?.__error ?? "skipped", + "SKIP", + ); + + // --- report --- + const w = (s: string, n: number) => s.padEnd(n).slice(0, n); + console.log( + w("CHECK", 42) + w("ON-CHAIN", 26) + w("API/INDEXED", 26) + "STATUS", + ); + console.log("-".repeat(102)); + for (const r of rows) + console.log(w(r.check, 42) + w(r.onchain, 26) + w(r.api, 26) + r.status); + const fails = rows.filter((r) => r.status === "FAIL").length; + const skips = rows.filter((r) => r.status === "SKIP").length; + console.log("-".repeat(102)); + console.log( + `\n${rows.length} checks · ${rows.length - fails - skips} pass · ${fails} FAIL · ${skips} skip` + + (API_BASE + ? "" + : " (on-chain-only; set API_BASE for full reconciliation)"), + ); + process.exit(fails > 0 ? 1 : 0); +} + +main().catch((e) => { + console.error("fatal:", e.message); + process.exit(2); +});