diff --git a/apps/api/src/clients/index.ts b/apps/api/src/clients/index.ts index 022f07d19..c099f22a3 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..761946515 --- /dev/null +++ b/apps/api/src/clients/torn/index.ts @@ -0,0 +1,196 @@ +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", + }); + }); + } + + async getVotingDelay(): Promise { + if (!this.cache.votingDelay) { + this.cache.votingDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_DELAY", + })) as bigint; + } + return this.cache.votingDelay!; + } + + async getVotingPeriod(): Promise { + if (!this.cache.votingPeriod) { + this.cache.votingPeriod = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_PERIOD", + })) as bigint; + } + 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 { + return votes.forVotes; + } + + 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 < 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/lib/client.ts b/apps/api/src/lib/client.ts index 3493aafd7..3a07b1bee 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 79b268a8d..7cc319342 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 fb99419a5..5a62b280d 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..ead95d15d 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 = { 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 ded0db330..8f592c354 100644 --- a/apps/dashboard/shared/components/icons/index.ts +++ b/apps/dashboard/shared/components/icons/index.ts @@ -15,6 +15,7 @@ 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 7c0621799..708e5655e 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 { UNI } from "@/shared/dao-config/uni"; import { AAVE } from "@/shared/dao-config/aave"; @@ -24,4 +25,5 @@ export default { COMP, OBOL, SHU, + TORN, } as const; diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts new file mode 100644 index 000000000..71468c72b --- /dev/null +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -0,0 +1,332 @@ +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: false, + timelock: true, + cancelFunction: false, + logic: "For", + 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 25,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.MEDIUM, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTE_MUTABILITY + ].description, + currentSetting: + "The DAO does not allow changing votes once they have been cast.", + impact: + "Governance participants cannot change their votes after casting them. In the event of an interface hijack on the voting platform to support the attack, voters cannot revert their vote.", + recommendedSetting: + RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTE_MUTABILITY], + nextStep: + "Allow voters to change their vote until the Voting Period ends.", + requirements: [ + "If voters cannot revise their ballots, a last-minute interface exploit or late discovery of malicious code can trap delegates in a choice that now favors an attacker, weakening the DAO's defense.", + "The governance contract should let any voter overwrite their previous vote while the voting window is open.", + ], + }, + [GovernanceImplementationEnum.VOTING_DELAY]: { + riskLevel: RiskLevel.HIGH, + description: + GOVERNANCE_IMPLEMENTATION_CONSTANTS[ + GovernanceImplementationEnum.VOTING_DELAY + ].description, + currentSetting: + "The Voting Delay is set to approximately 1 block (~12 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, and immutable votes limit recovery from front-end compromise, resulting in moderate governance interface risk.", + }, + }, + }, + resilienceStages: true, + tokenDistribution: true, + dataTables: true, + governancePage: true, +}; diff --git a/apps/dashboard/shared/og/dao-og-icons.tsx b/apps/dashboard/shared/og/dao-og-icons.tsx index 1e2bc1622..01c4b3d05 100644 --- a/apps/dashboard/shared/og/dao-og-icons.tsx +++ b/apps/dashboard/shared/og/dao-og-icons.tsx @@ -238,3 +238,30 @@ export function ShutterOgIcon({ size }: { size: number }) { ); } + +export function TornadoCashOgIcon({ size }: { size: number }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/dashboard/shared/types/daos.ts b/apps/dashboard/shared/types/daos.ts index d97193c60..4127d9d0d 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 interface DAO { 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/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..d33b5865f --- /dev/null +++ b/apps/indexer/src/indexer/torn/INTEGRATION.md @@ -0,0 +1,113 @@ +# 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 + +### No per-account votingPowerHistory + +The standard pattern relies on `DelegateVotesChanged` events which TORN doesn't emit. Voting power = `lockedBalance` in the governor. We track aggregate `delegatedSupply` (total locked TORN) but individual `votingPowerHistory` records are not populated. + +**To close this gap:** Detect Transfer events to/from the governance contract and create synthetic `votingPowerHistory` entries. Requires careful handling when delegation shifts occur (voting power moves between accounts without a Transfer). + +### 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. + +### 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, with incremental tally updates. After backfill, tallies should be reconciled from vote rows via SQL. + +### 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/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..0774d8998 --- /dev/null +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -0,0 +1,182 @@ +import { ponder } from "ponder:registry"; +import { token } from "ponder:schema"; +import { Address, getAddress } from "viem"; + +import { tokenTransfer } from "@/eventHandlers"; +import { + updateDelegatedSupply, + updateCirculatingSupply, + updateSupplyMetric, + updateTotalSupply, +} from "@/eventHandlers/metrics"; +import { handleTransaction } from "@/eventHandlers/shared"; +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, + ); + + 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: transfers to/from the governance contract + const normalizedTo = getAddress(to); + const normalizedFrom = getAddress(from); + + if (normalizedTo === governorAddress) { + // Locking TORN into governance + await updateDelegatedSupply(context, daoId, address, value, timestamp); + } + + if (normalizedFrom === governorAddress) { + // Unlocking TORN from governance + await updateDelegatedSupply(context, daoId, address, -value, timestamp); + } + + if (!event.transaction.to) return; + + await handleTransaction( + context, + event.transaction.hash, + event.transaction.from, + event.transaction.to, + event.block.timestamp, + [event.args.from, event.args.to], + { + cex: cexAddressList, + dex: dexAddressList, + lending: lendingAddressList, + burning: burningAddressList, + }, + ); + }); +} diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts new file mode 100644 index 000000000..a32a51e59 --- /dev/null +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -0,0 +1,258 @@ +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, + status: 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); + + 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, + })); + + // onConflictDoNothing prevents DelayedInsertError from Ponder's batch flush + 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, + }) + .onConflictDoNothing(); + + 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, + ); + }); + + 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/docs/superpowers/plans/2026-03-25-tornado-cash-integration.md b/docs/superpowers/plans/2026-03-25-tornado-cash-integration.md new file mode 100644 index 000000000..05f0e8569 --- /dev/null +++ b/docs/superpowers/plans/2026-03-25-tornado-cash-integration.md @@ -0,0 +1,1162 @@ +# Tornado Cash DAO Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Full integration of Tornado Cash DAO (TORN) into the Anticapture platform — indexer, API, gateway, and dashboard. + +**Architecture:** Tornado Cash uses a custom stake-to-vote governance (not OZ Governor). TORN tokens are locked in the Governance contract for voting power. The governor emits `Voted`, `ProposalCreated`, `ProposalExecuted`, `Delegated`, `Undelegated` events. The token emits standard `Transfer` events only (no delegation events). All intermediate proposal states (Active, Defeated, Timelocked, etc.) are computed at the API level via timestamp comparisons. + +**Tech Stack:** Ponder (indexer), Hono + Drizzle (API), viem, TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-25-tornado-cash-integration-design.md` + +**RPC:** Merkle RPC blocks Tornado Cash (sanctioned). Use user's local reth node or Llama RPC (`eth.llamarpc.com`). + +**Working directory:** `/home/nodeful/anticapture/.worktrees/tornado-cash` + +--- + +### Task 1: Enum Sync + +**Files:** + +- Modify: `apps/indexer/src/lib/enums.ts` +- Modify: `apps/api/src/lib/enums.ts` +- Modify: `apps/dashboard/shared/types/daos.ts` + +- [ ] **Step 1: Add TORN to indexer enum** + +```typescript +// apps/indexer/src/lib/enums.ts — add to DaoIdEnum +TORN = "TORN", +``` + +- [ ] **Step 2: Add TORN to API enum** + +```typescript +// apps/api/src/lib/enums.ts — add to DaoIdEnum +TORN = "TORN", +``` + +- [ ] **Step 3: Add TORN to dashboard enum** + +```typescript +// apps/dashboard/shared/types/daos.ts — add to DaoIdEnum +TORN = "TORN", +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/indexer/src/lib/enums.ts apps/api/src/lib/enums.ts apps/dashboard/shared/types/daos.ts +git commit -m "feat(torn): add TORN to DaoIdEnum across all packages" +``` + +--- + +### Task 2: Indexer Constants + +**Files:** + +- Modify: `apps/indexer/src/lib/constants.ts` + +- [ ] **Step 1: Add CONTRACT_ADDRESSES entry** + +Add after the last entry in `CONTRACT_ADDRESSES` (before `} as const`): + +```typescript +[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, + }, +}, +``` + +- [ ] **Step 2: Add address list entries** + +Add `[DaoIdEnum.TORN]: {}` to each of: `TreasuryAddresses`, `CEXAddresses`, `DEXAddresses`, `LendingAddresses`, `BurningAddresses`. + +Add the governance contract to `NonCirculatingAddresses` (locked TORN is not circulating): + +```typescript +[DaoIdEnum.TORN]: { + governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", +}, +``` + +- [ ] **Step 3: Typecheck** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm indexer typecheck +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/indexer/src/lib/constants.ts +git commit -m "feat(torn): add TORN contract addresses and address lists" +``` + +--- + +### Task 3: Indexer ABIs + +**Files:** + +- Create: `apps/indexer/src/indexer/torn/abi/token.ts` +- Create: `apps/indexer/src/indexer/torn/abi/governor.ts` +- Create: `apps/indexer/src/indexer/torn/abi/index.ts` + +- [ ] **Step 1: Create token ABI** + +```typescript +// apps/indexer/src/indexer/torn/abi/token.ts +// 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; +``` + +- [ ] **Step 2: Create governor ABI** + +```typescript +// apps/indexer/src/indexer/torn/abi/governor.ts +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; +``` + +- [ ] **Step 3: Create index re-export** + +```typescript +// apps/indexer/src/indexer/torn/abi/index.ts +export { TORNTokenAbi } from "./token"; +export { TORNGovernorAbi } from "./governor"; +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/indexer/src/indexer/torn/abi/ +git commit -m "feat(torn): add TORN token and governor ABIs" +``` + +--- + +### Task 4: Indexer Token Handler + +**Files:** + +- Create: `apps/indexer/src/indexer/torn/erc20.ts` + +Reference: `apps/indexer/src/indexer/ens/erc20.ts` + +- [ ] **Step 1: Create token handler** + +Follow the ENS pattern exactly, but: + +- Contract name prefix: `TORN` (Ponder event: `TORNToken:Transfer`) +- No `DelegateChanged` or `DelegateVotesChanged` handlers (TORN doesn't emit them) +- Add lock tracking: detect transfers to/from the governance contract and call `updateDelegatedSupply()` + +```typescript +// apps/indexer/src/indexer/torn/erc20.ts +import { ponder } from "ponder:registry"; +import { token } from "ponder:schema"; +import { Address, getAddress } from "viem"; + +import { tokenTransfer } from "@/eventHandlers"; +import { + updateDelegatedSupply, + updateCirculatingSupply, + updateSupplyMetric, + updateTotalSupply, +} from "@/eventHandlers/metrics"; +import { handleTransaction } from "@/eventHandlers/shared"; +import { + MetricTypesEnum, + BurningAddresses, + CEXAddresses, + CONTRACT_ADDRESSES, + DEXAddresses, + LendingAddresses, + TreasuryAddresses, + NonCirculatingAddresses, +} from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +const GOVERNANCE_ADDRESS = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].governor.address, +); + +export function TORNTokenIndexer(address: Address, decimals: number) { + const daoId = DaoIdEnum.TORN; + + 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, + 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); + + // Lock tracking: transfers to/from governance contract update delegatedSupply + const normalizedTo = getAddress(to); + const normalizedFrom = getAddress(from); + + if (normalizedTo === GOVERNANCE_ADDRESS) { + // Locking TORN → voting power pool increases + await updateDelegatedSupply(context, daoId, address, value, timestamp); + } else if (normalizedFrom === GOVERNANCE_ADDRESS) { + // Unlocking TORN → voting power pool decreases + await updateDelegatedSupply(context, daoId, address, -value, timestamp); + } + + if (!event.transaction.to) return; + + await handleTransaction( + context, + event.transaction.hash, + event.transaction.from, + event.transaction.to, + event.block.timestamp, + [event.args.from, event.args.to], + { + cex: cexAddressList, + dex: dexAddressList, + lending: lendingAddressList, + burning: burningAddressList, + }, + ); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/indexer/src/indexer/torn/erc20.ts +git commit -m "feat(torn): add TORN token handler with lock-based delegatedSupply tracking" +``` + +--- + +### Task 5: Indexer Governor Handler + +**Files:** + +- Create: `apps/indexer/src/indexer/torn/governor.ts` + +Reference: `apps/indexer/src/indexer/shu/governor.ts` (custom ProposalCreated pattern) + +- [ ] **Step 1: Create governor handler** + +Custom handler because Tornado Cash's `ProposalCreated` uses timestamps (not block numbers) and its `Voted` event uses `bool support` (not `uint8`). Follows SHU pattern of writing directly to schema. + +```typescript +// apps/indexer/src/indexer/torn/governor.ts +import { ponder } from "ponder:registry"; +import { accountPower, feedEvent, proposalsOnchain } from "ponder:schema"; +import { getAddress } from "viem"; + +import { + delegateChanged, + updateProposalStatus, + voteCast, +} from "@/eventHandlers"; +import { ensureAccountExists } from "@/eventHandlers/shared"; +import { CONTRACT_ADDRESSES, ProposalStatus } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +const TORN_TOKEN_ADDRESS = CONTRACT_ADDRESSES[DaoIdEnum.TORN].token.address; + +const MAX_TITLE_LENGTH = 200; + +function parseProposalTitle(description: string): string { + const normalized = description.replace(/\\n/g, "\n"); + const lines = normalized.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^# /.test(trimmed)) { + return trimmed.replace(/^# +/, ""); + } + break; + } + + 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 ""; +} + +export function TORNGovernorIndexer(blockTime: number) { + const daoId = DaoIdEnum.TORN; + + /** + * ProposalCreated — custom handler (not shared proposalCreated()). + * + * Tornado Cash provides startTime/endTime as timestamps, not block numbers. + * We store synthetic block numbers for schema compatibility. + */ + 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 compatibility + const startBlockEstimate = + Number(event.block.number) + + Math.floor( + (Number(startTime) - Number(event.block.timestamp)) / blockTime, + ); + const endBlockEstimate = + 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: [0n], + signatures: [], + calldatas: [], + startBlock: startBlockEstimate, + endBlock: endBlockEstimate, + title, + description, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + status: ProposalStatus.PENDING, + 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 — bool support mapped to number: true→1 (for), false→0 (against). + * No abstain in Tornado Cash. No reason field. + */ + ponder.on("TORNGovernor:Voted", async ({ event, context }) => { + const { proposalId, voter, support, votes } = event.args; + + await voteCast(context, daoId, { + proposalId: proposalId.toString(), + voter, + reason: "", + support: support ? 1 : 0, + timestamp: event.block.timestamp, + txHash: event.transaction.hash, + votingPower: votes, + logIndex: event.log.logIndex, + }); + }); + + ponder.on("TORNGovernor:ProposalExecuted", async ({ event, context }) => { + await updateProposalStatus( + context, + event.args.proposalId.toString(), + ProposalStatus.EXECUTED, + ); + }); + + /** + * Delegated — governor-level delegation (not token-level). + * Maps to delegateChanged() using the TORN token address as tokenId + * for data model consistency. + */ + ponder.on("TORNGovernor:Delegated", async ({ event, context }) => { + const { account, to } = event.args; + + // Look up current delegate from accountBalance to determine previousDelegate + const { accountBalance } = await import("ponder:schema"); + const existing = await context.db.find(accountBalance, { + accountId: getAddress(account), + tokenId: getAddress(TORN_TOKEN_ADDRESS), + }); + const previousDelegate = existing?.delegate ?? getAddress(account); + + await delegateChanged(context, daoId, { + delegator: account, + delegate: to, + tokenId: getAddress(TORN_TOKEN_ADDRESS), + previousDelegate, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); + + /** + * Undelegated — reverts delegation to self. + */ + ponder.on("TORNGovernor:Undelegated", async ({ event, context }) => { + const { account, from } = event.args; + + await delegateChanged(context, daoId, { + delegator: account, + delegate: account, // reverts to self + tokenId: getAddress(TORN_TOKEN_ADDRESS), + previousDelegate: from, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/indexer/src/indexer/torn/governor.ts +git commit -m "feat(torn): add custom governor handler with timestamp-based proposals" +``` + +--- + +### Task 6: Indexer Entry Point and Config + +**Files:** + +- Create: `apps/indexer/src/indexer/torn/index.ts` +- Create: `apps/indexer/config/torn.config.ts` +- Modify: `apps/indexer/ponder.config.ts` +- Modify: `apps/indexer/src/index.ts` + +- [ ] **Step 1: Create index re-export** + +```typescript +// apps/indexer/src/indexer/torn/index.ts +export { TORNTokenAbi, TORNGovernorAbi } from "./abi"; +export { TORNTokenIndexer } from "./erc20"; +export { TORNGovernorIndexer } from "./governor"; +``` + +- [ ] **Step 2: Create Ponder config** + +```typescript +// apps/indexer/config/torn.config.ts +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, + }, + }, +}); +``` + +- [ ] **Step 3: Wire into ponder.config.ts** + +Add import and spread into the merged config, following the existing pattern. + +```typescript +import tornConfig from "./config/torn.config"; +// ...spread into chains/contracts +``` + +- [ ] **Step 4: Wire into src/index.ts** + +Add import and switch case: + +```typescript +import { TORNTokenIndexer, TORNGovernorIndexer } from "@/indexer/torn"; + +// In switch: +case DaoIdEnum.TORN: { + TORNTokenIndexer(token.address, token.decimals); + TORNGovernorIndexer(blockTime); + break; +} +``` + +- [ ] **Step 5: Typecheck and lint** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm indexer typecheck && pnpm indexer lint +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/indexer/src/indexer/torn/index.ts apps/indexer/config/torn.config.ts apps/indexer/ponder.config.ts apps/indexer/src/index.ts +git commit -m "feat(torn): wire TORN indexer into Ponder config and entry point" +``` + +--- + +### Task 7: INTEGRATION.md + +**Files:** + +- Create: `apps/indexer/src/indexer/torn/INTEGRATION.md` + +- [ ] **Step 1: Create INTEGRATION.md** + +Document the architecture, what's integrated, what's pending, and verification results. Follow the template from the DAO integration skill. Include: + +1. Architecture table (Token, Governor, Staking, Vault contracts) +2. Governor voting token: `lockedBalance` in Governance contract (not standard delegation) +3. What's integrated checklist +4. What's pending (votingPowerHistory, abstain votes, vote extension, staking rewards, etc.) +5. Notes on custom governance, OFAC sanctions context, governance attack history + +- [ ] **Step 2: Commit** + +```bash +git add apps/indexer/src/indexer/torn/INTEGRATION.md +git commit -m "docs(torn): add INTEGRATION.md documenting architecture and gaps" +``` + +--- + +### Task 8: API Constants and Client + +**Files:** + +- Modify: `apps/api/src/lib/constants.ts` +- Create: `apps/api/src/clients/torn/abi.ts` +- Create: `apps/api/src/clients/torn/index.ts` +- Modify: `apps/api/src/clients/index.ts` +- Modify: `apps/api/src/lib/client.ts` + +- [ ] **Step 1: Add API constants** + +Add `CONTRACT_ADDRESSES[DaoIdEnum.TORN]` to `apps/api/src/lib/constants.ts` mirroring the indexer, and `TreasuryAddresses[DaoIdEnum.TORN]`. + +- [ ] **Step 2: Create API governor ABI** + +```typescript +// apps/api/src/clients/torn/abi.ts +// Tornado Cash uses SCREAMING_SNAKE_CASE function names +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; +``` + +- [ ] **Step 3: Create TORNClient** + +```typescript +// apps/api/src/clients/torn/index.ts +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"; + +// On-chain governance parameters (cached after first RPC call) +// These are in SECONDS, not blocks — Tornado Cash uses timestamp-based governance +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; + private executionDelay?: bigint; + private executionExpiration?: bigint; + + 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 (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "QUORUM_VOTES", + args: [], + })) as bigint; + }); + } + + async getVotingDelay(): Promise { + if (!this.cache.votingDelay) { + this.cache.votingDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_DELAY", + args: [], + })) as bigint; + } + return this.cache.votingDelay!; + } + + async getVotingPeriod(): Promise { + if (!this.cache.votingPeriod) { + this.cache.votingPeriod = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_PERIOD", + args: [], + })) as bigint; + } + 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", + args: [], + })) as bigint; + } + return this.cache.proposalThreshold!; + } + + async getTimelockDelay(): Promise { + if (!this.executionDelay) { + this.executionDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_DELAY", + args: [], + })) as bigint; + } + return this.executionDelay; + } + + private async getExecutionExpiration(): Promise { + if (!this.executionExpiration) { + this.executionExpiration = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_EXPIRATION", + args: [], + })) as bigint; + } + return this.executionExpiration; + } + + /** + * Tornado Cash uses timestamp-based governance — override block-based logic. + * + * State machine (all timestamp comparisons): + * currentTimestamp < startTime → PENDING + * currentTimestamp < endTime → ACTIVE + * forVotes <= againstVotes || forVotes < quorum → DEFEATED + * proposal.executed (status === EXECUTED) → EXECUTED + * currentTimestamp < endTime + EXECUTION_DELAY → QUEUED (Timelocked) + * currentTimestamp < endTime + 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 by indexer event + if (proposal.status === ProposalStatus.EXECUTED) { + return ProposalStatus.EXECUTED; + } + + const now = BigInt(currentTimestamp); + const endTime = proposal.endTimestamp; + + // Estimate startTime from synthetic startBlock + const startTime = + endTime - + BigInt(proposal.endBlock - proposal.startBlock) * BigInt(BLOCK_TIME); + + // Before voting + if (now < startTime) { + return ProposalStatus.PENDING; + } + + // During voting + if (now < endTime) { + return ProposalStatus.ACTIVE; + } + + // After voting — check outcome + const quorum = await this.getQuorum(proposal.id); + const hasQuorum = proposal.forVotes >= quorum; + const hasMajority = proposal.forVotes > proposal.againstVotes; + + if (!hasQuorum) return ProposalStatus.NO_QUORUM; + if (!hasMajority) return ProposalStatus.DEFEATED; + + // Proposal passed — check timelock windows + const executionDelay = await this.getTimelockDelay(); + const executionExpiration = await this.getExecutionExpiration(); + + if (now < endTime + executionDelay) { + return ProposalStatus.QUEUED; + } + + if (now < endTime + executionDelay + executionExpiration) { + return ProposalStatus.PENDING_EXECUTION; + } + + return ProposalStatus.EXPIRED; + } + + calculateQuorum(votes: { + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + }): bigint { + // Tornado Cash quorum: forVotes must meet threshold (no abstain) + return votes.forVotes; + } + + alreadySupportCalldataReview(): boolean { + return false; + } +} +``` + +- [ ] **Step 4: Register client export** + +Add to `apps/api/src/clients/index.ts`: + +```typescript +export * from "./torn"; +``` + +- [ ] **Step 5: Add to client factory** + +Add to `apps/api/src/lib/client.ts` switch statement: + +```typescript +case DaoIdEnum.TORN: { + const { governor } = CONTRACT_ADDRESSES[daoId]; + return new TORNClient(client, governor.address); +} +``` + +Import `TORNClient` at the top. + +- [ ] **Step 6: Typecheck and lint** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm api typecheck && pnpm api lint +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/lib/constants.ts apps/api/src/clients/torn/ apps/api/src/clients/index.ts apps/api/src/lib/client.ts +git commit -m "feat(torn): add TORNClient with timestamp-based proposal status" +``` + +--- + +### Task 9: Dashboard Config + +**Files:** + +- Create: `apps/dashboard/shared/dao-config/torn.ts` +- Modify: `apps/dashboard/shared/dao-config/index.ts` + +- [ ] **Step 1: Create dashboard config** + +Follow `apps/dashboard/shared/dao-config/ens.ts` as template. Key differences: + +- `name: "Tornado Cash"` +- `decimals: 18` +- `color: { svgColor: "#94FEBF", svgBgColor: "#1a1a2e" }` +- `daoOverview.rules.cancelFunction: false` +- `daoOverview.rules.logic: "For"` (binary voting, no abstain) +- `daoOverview.rules.quorumCalculation: "Fixed at 100,000 TORN"` +- Feature flags all true + +- [ ] **Step 2: Register in index** + +Add to `apps/dashboard/shared/dao-config/index.ts`: + +```typescript +import { TORN } from "@/shared/dao-config/torn"; +// Add TORN to the export object +``` + +- [ ] **Step 3: Typecheck and lint** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm dashboard typecheck && pnpm dashboard lint +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/dashboard/shared/dao-config/torn.ts apps/dashboard/shared/dao-config/index.ts +git commit -m "feat(torn): add Tornado Cash dashboard configuration" +``` + +--- + +### Task 10: Verification — Run Indexer Locally + +**Prerequisites:** Local PostgreSQL and reth node available. + +- [ ] **Step 1: Set up environment** + +Create local `.env` for TORN indexing (don't commit): + +```bash +DAO_ID=TORN +DATABASE_URL=postgresql://... +RPC_URL=http://localhost:8545 # local reth node +``` + +- [ ] **Step 2: Run indexer** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm indexer dev +``` + +Let it sync. Monitor for errors. + +- [ ] **Step 3: Verify proposal count** + +Query the database for proposal count and compare against on-chain `proposalCount()` = 65. + +- [ ] **Step 4: Verify proposal states** + +For each proposal, compare indexed status against on-chain `state(proposalId)`. Map: 0=PENDING, 1=ACTIVE, 2=DEFEATED, 3=QUEUED, 4=PENDING_EXECUTION, 5=EXECUTED, 6=EXPIRED. + +- [ ] **Step 5: Verify vote tallies** + +For sample proposals (e.g., 1, 10, 30, 65), compare indexed `forVotes`/`againstVotes` against on-chain `proposals(id)` struct. + +- [ ] **Step 6: Verify delegatedSupply** + +Compare indexed `delegatedSupply` (token table) against TORN balance of governance contract: + +```bash +cast call 0x77777FeDdddFfC19Ff86DB637967013e6C6A116C "balanceOf(address)(uint256)" 0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce --rpc-url +``` + +- [ ] **Step 7: Verify token total supply** + +Compare indexed total supply against on-chain `totalSupply()`. + +- [ ] **Step 8: Document verification results in INTEGRATION.md** + +--- + +### Task 11: Final Typecheck and PR + +- [ ] **Step 1: Full monorepo typecheck** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm typecheck +``` + +- [ ] **Step 2: Full lint** + +```bash +cd /home/nodeful/anticapture/.worktrees/tornado-cash && pnpm lint +``` + +- [ ] **Step 3: Fix any errors** + +- [ ] **Step 4: Open PR** + +```bash +gh pr create --base dev --title "feat: integrate Tornado Cash DAO (TORN)" --body "..." +``` diff --git a/docs/superpowers/specs/2026-03-25-tornado-cash-integration-design.md b/docs/superpowers/specs/2026-03-25-tornado-cash-integration-design.md new file mode 100644 index 000000000..fdd8c7096 --- /dev/null +++ b/docs/superpowers/specs/2026-03-25-tornado-cash-integration-design.md @@ -0,0 +1,243 @@ +# Tornado Cash DAO Integration Design + +## Summary + +Full integration of Tornado Cash DAO into the Anticapture platform — indexer, API, gateway, and dashboard. Tornado Cash uses a **custom stake-to-vote governance** (not OZ Governor or Compound GovernorBravo), which requires custom event handlers while mapping to the existing shared schema. + +## Contracts + +| Contract | Address | Deploy Block | Purpose | +| --------------------- | -------------------------------------------- | ------------ | -------------------------------------- | +| TORN Token | `0x77777FeDdddFfC19Ff86DB637967013e6C6A116C` | 11,474,599 | ERC20 governance token (18 decimals) | +| Governance | `0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce` | 11,474,695 | Stake-to-vote governor (LoopbackProxy) | +| TornadoStakingRewards | `0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29` | 17,546,145 | Relayer fee staking rewards | +| TornadoVault | `0x2F50508a8a3D323B91336FA3eA6ae50E55f32185` | 13,429,786 | TORN vault for governance withdrawals | + +## Governance Architecture + +Tornado Cash does NOT use OpenZeppelin Governor or Compound GovernorAlpha/Bravo. Key differences: + +1. **Stake-to-vote**: Users lock TORN into the Governance contract. `lockedBalance` = voting power. No token-level delegation. +2. **Governor-level delegation**: `Delegated`/`Undelegated` events are emitted by the Governance contract, not the token. +3. **No vote checkpointing**: Voting power is the current `lockedBalance` at time of vote — no snapshots. +4. **Built-in timelock**: No separate timelock contract. `EXECUTION_DELAY` (2 days) is baked into the governor. +5. **Binary voting**: `bool support` (for/against). No abstain option. +6. **Proposal = contract**: Proposals are deployed contracts with `executeProposal()`, executed via `delegatecall` from the governor. + +### Governance Parameters + +| Parameter | Value | +| -------------------- | ------------ | +| Voting Delay | 75 seconds | +| Voting Period | 5 days | +| Quorum | 100,000 TORN | +| Proposal Threshold | 1,000 TORN | +| Execution Delay | 2 days | +| Execution Expiration | 3 days | +| Closing Period | 1 hour | +| Vote Extend Time | 6 hours | + +### Proposal States + +| On-chain State | Value | Maps to Anticapture | How tracked | +| ----------------- | ----- | ------------------- | ------------------------------ | +| Pending | 0 | PENDING | Computed by API (time-based) | +| Active | 1 | ACTIVE | Computed by API (time-based) | +| Defeated | 2 | DEFEATED | Computed by API (vote tallies) | +| Timelocked | 3 | QUEUED | Computed by API (time-based) | +| AwaitingExecution | 4 | PENDING_EXECUTION | Computed by API (time-based) | +| Executed | 5 | EXECUTED | Indexed via ProposalExecuted | +| Expired | 6 | EXPIRED | Computed by API (time-based) | + +**Note**: Unlike OZ governors, Tornado Cash does NOT emit events for state transitions (no `ProposalQueued`, no `ProposalCanceled`). Only `ProposalCreated` and `ProposalExecuted` are emitted. All intermediate states (Pending, Active, Defeated, Timelocked, AwaitingExecution, Expired) are computed at the API level via `TORNClient.getProposalStatus()` using timestamp comparisons. The indexer sets initial status to PENDING on `ProposalCreated` and EXECUTED on `ProposalExecuted`. + +### Event Signatures (Governor) + +``` +ProposalCreated(uint256 indexed id, address indexed proposer, address target, uint256 startTime, uint256 endTime, string description) +Voted(uint256 indexed proposalId, address indexed voter, bool indexed support, uint256 votes) +ProposalExecuted(uint256 indexed proposalId) +Delegated(address indexed account, address indexed to) +Undelegated(address indexed account, address indexed from) +``` + +### Event Signatures (Token) + +Standard ERC20: `Transfer(address indexed from, address indexed to, uint256 value)` + +No `DelegateChanged` or `DelegateVotesChanged` — TORN does not have built-in delegation. + +## Design + +### Step 1: Enum Sync + +Add `TORN = "TORN"` to `DaoIdEnum` in all three locations: + +- `apps/indexer/src/lib/enums.ts` +- `apps/api/src/lib/enums.ts` +- `apps/dashboard/shared/types/daos.ts` + +### Step 2: Indexer + +#### Token Handler (`erc20.ts`) + +Handle `Transfer` events using the standard `tokenTransfer()` utility: + +- Balance tracking for all accounts +- CEX/DEX/Lending/Treasury/NonCirculating supply classification +- Circulating supply calculation + +**Lock tracking via Transfer**: When TORN is transferred to/from the Governance contract address (`0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce`): + +- Transfer TO governor = lock (user locks TORN for voting power) +- Transfer FROM governor = unlock (user withdraws TORN) +- Use these to update `delegatedSupply` via `updateDelegatedSupply()` — locked TORN represents the total voting power pool +- Amount: `+value` for lock (TO governor), `-value` for unlock (FROM governor) + +**Address classification**: The governance contract address must be added to `NonCirculatingAddresses[DaoIdEnum.TORN]` since locked TORN is not part of circulating supply. + +#### Governor Handler (`governor.ts`) — Custom Implementation + +The governor handler uses **custom event handlers** (not the shared `proposalCreated()` utility) because Tornado Cash's `ProposalCreated` event provides timestamps, not block numbers. This follows the SHU precedent of writing directly to the schema when the shared utility doesn't fit. + +**`Voted` event** → mapped to `voteCast()`: + +- `bool support` converted via `event.args.support ? 1 : 0` (for=1, against=0) +- No abstain support (Tornado Cash doesn't have it) +- `votes` field provides the voting power used +- `reason` field: pass empty string `""` (Tornado Cash `Voted` event has no reason parameter) + +**`ProposalCreated` event** → custom handler (NOT shared `proposalCreated()`): + +- Writes directly to `proposalsOnchain` table +- `startTime` and `endTime` are stored as-is (timestamps, not block numbers) +- `startBlock` / `endBlock` fields: store synthetic values by converting timestamps to estimated block numbers using `(timestamp - event.block.timestamp) / blockTime + event.block.number` +- `target` is the proposal contract address (single target, not arrays) +- Initial status set to `PENDING` + +**`ProposalExecuted` event** → mapped to `updateProposalStatus(EXECUTED)` + +**`Delegated` event** → mapped to `delegateChanged()`: + +- `delegator = event.args.account`, `delegate = event.args.to` +- `previousDelegate` determined from `accountBalance.delegate` state +- `tokenId` = TORN token address (not the governor address), to maintain consistency with how other DAOs key delegation records in the data model + +**`Undelegated` event** → mapped to `delegateChanged()`: + +- `delegator = event.args.account`, `delegate = event.args.account` (self) +- `previousDelegate = event.args.from` +- `tokenId` = TORN token address + +#### ABIs (`apps/indexer/src/indexer/torn/abi/`) + +- `token.ts`: Standard ERC20 ABI (Transfer event only — no delegation events) +- `governor.ts`: Custom ABI for Tornado Cash governance (ProposalCreated, Voted, ProposalExecuted, Delegated, Undelegated) +- `index.ts`: Re-exports both as `TORNTokenAbi` and `TORNGovernorAbi` + +#### Ponder Config (`apps/indexer/config/torn.config.ts`) + +- Chain: `ethereum_mainnet` (chain ID 1) +- Two contracts: `TORNToken` and `TORNGovernor` +- Start blocks: token at 11,474,599, governor at 11,474,695 + +#### Constants (`apps/indexer/src/lib/constants.ts`) + +Add `CONTRACT_ADDRESSES[DaoIdEnum.TORN]` with token and governor entries. Add governance contract to `NonCirculatingAddresses`. Add known CEX/DEX/Treasury addresses if available. + +#### Wiring + +- Import `torn.config.ts` in `apps/indexer/ponder.config.ts` and spread chains/contracts +- Add switch case in `apps/indexer/src/index.ts` for `DaoIdEnum.TORN` + +### Step 3: API + +#### Client (`apps/api/src/clients/torn/index.ts`) + +`TORNClient extends GovernorBase implements DAOClient`: + +- `getDaoId()` → `"TORN"` +- **Override** `getQuorum()` → calls `QUORUM_VOTES()` on governor (SCREAMING_SNAKE_CASE, not camelCase) +- **Override** `getTimelockDelay()` → calls `EXECUTION_DELAY()` on governor (no separate timelock) +- `calculateQuorum(votes)` → `votes.forVotes` (Tornado requires forVotes > quorum, no abstain counted) +- `alreadySupportCalldataReview()` → `false` +- **Override** `getVotingDelay()` → calls `VOTING_DELAY()` (SCREAMING_SNAKE_CASE, returns seconds) +- **Override** `getVotingPeriod()` → calls `VOTING_PERIOD()` (SCREAMING_SNAKE_CASE, returns seconds) +- **Override** `getProposalThreshold()` → calls `PROPOSAL_THRESHOLD()` (SCREAMING_SNAKE_CASE) +- **Override** `getProposalStatus()` → **timestamp-based status computation** instead of block-based. This is critical because the base `GovernorBase.getProposalStatus()` compares `currentBlock` against `startBlock`/`endBlock`, but Tornado Cash uses timestamp-based governance. The override must use `currentTimestamp` against `startTime`/`endTime` and apply Tornado Cash's state machine logic: Pending → Active → (Defeated | Timelocked → AwaitingExecution → (Executed | Expired)) + +#### ABI + +Create `apps/api/src/clients/torn/abi.ts` with the governor ABI containing the SCREAMING_SNAKE_CASE function signatures. + +#### Wiring + +- Export from `apps/api/src/clients/index.ts` +- Add to `getClient()` factory in `apps/api/src/lib/client.ts` + +#### Constants (`apps/api/src/lib/constants.ts`) + +Mirror `CONTRACT_ADDRESSES[DaoIdEnum.TORN]` from the indexer. + +### Step 4: Gateway + +Add `DAO_API_TORN=` env var. No code changes needed. + +### Step 5: Dashboard + +#### DAO Config (`apps/dashboard/shared/dao-config/torn.ts`) + +- Color scheme: Tornado Cash green `#94FEBF` or similar +- Icon component needed in `apps/dashboard/shared/components/icons/` +- Feature flags: `resilienceStages: true`, `tokenDistribution: true`, `dataTables: true`, `governancePage: true` +- `daoOverview.rules`: + - `delay: true` (75s voting delay) + - `changeVote: false` + - `timelock: true` (built-in 2-day execution delay) + - `cancelFunction: false` (no cancel — proposals are immutable contracts) + - `logic: "For"` (binary: for/against, no abstain) + - `quorumCalculation: "Fixed at 100,000 TORN"` + +#### Wiring + +- Register config in `apps/dashboard/shared/dao-config/index.ts` +- Add to `DaoIdEnum` in `apps/dashboard/shared/types/daos.ts` (covered in Step 1) + +### RPC Considerations + +Merkle RPC (`eth.merkle.io`) blocks calls to Tornado Cash contracts with `"sanctioned"` error. Must use an alternative: + +- User's local reth node (for local dev/testing and indexer) +- Llama RPC (`eth.llamarpc.com`) works but is unreliable (502 errors observed) +- Production: configure a non-censoring RPC endpoint via `RPC_URL` env var (e.g., user's own reth node or a private RPC provider) + +## Verification Strategy + +After implementation, verify data accuracy against on-chain state: + +1. **Token supply**: Compare `totalSupply` from indexer vs `cast call` result (~10M TORN) +2. **Proposal count**: Verify all 65 proposals are indexed with correct states +3. **Proposal states**: Compare each proposal's mapped status against `state(proposalId)` on-chain +4. **Vote tallies**: For a sample of proposals, compare `forVotes`/`againstVotes` against on-chain `proposals(id)` struct +5. **Delegation**: Verify a sample of `delegatedTo(address)` on-chain matches indexed delegation state +6. **Locked supply (delegatedSupply)**: Compare total TORN balance of governance contract vs indexed `delegatedSupply` + +## What's Pending / Limitations + +Document these in `INTEGRATION.md`: + +1. **No per-account `votingPowerHistory`**: The standard pattern relies on `DelegateVotesChanged` events which TORN doesn't emit. Voting power = `lockedBalance` in the governor, which changes via lock/unlock (Transfer events). We track `delegatedSupply` as the aggregate, but individual `votingPowerHistory` records won't be populated. To add this, we'd need to detect transfers to/from the governor and create synthetic `votingPowerHistory` entries — achievable but requires careful handling of delegation shifts. + +2. **No abstain votes**: Tornado Cash uses binary voting (`bool support`). The `abstainVotes` field on proposals will always be 0. Dashboard should hide the abstain column for TORN. + +3. **Vote extension mechanism**: If a vote outcome flips in the last hour (`CLOSING_PERIOD`), voting extends by 6 hours (`VOTE_EXTEND_TIME`). Our indexer won't track this dynamic — `endTime` from `ProposalCreated` is the initial end time. The actual end time may differ for proposals where the extension triggered. This could cause brief status mismatches during the extension window. + +4. **Staking rewards**: The `TornadoStakingRewards` contract distributes relayer fees to locked TORN holders. This is an economic dimension not captured by our indexer. It doesn't affect governance voting directly but is relevant context. + +5. **Governance attacks / upgrades**: The contract has been upgraded multiple times (current version: `"5.proposal-state-patch"`), including after the May 2023 governance attack where an attacker gained majority voting power via a malicious proposal. Our indexer will process all events from genesis, including the attack period. The indexed data will accurately reflect this period, which may show unusual voting power concentration on the dashboard. + +6. **Cancel function**: Tornado Cash proposals cannot be canceled once created (no `ProposalCanceled` event). The `cancelFunction` rule should be set to `false`. + +7. **Proposal target decoding**: Proposals are deployed contracts executed via `delegatecall`. We index the `target` address but cannot decode what the proposal does without reading the target contract's bytecode. `alreadySupportCalldataReview` should be `false`. + +8. **Timestamp-based governance timing**: All governance parameters (voting delay, period, execution delay) are in seconds, not blocks. The `TORNClient.getProposalStatus()` override uses timestamp comparisons. Synthetic block numbers are stored for schema compatibility but are estimates.