From 451db65d6497503ecebcae24fed44027a2e6479f Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Sun, 28 Jun 2026 18:58:17 -0300 Subject: [PATCH 01/44] feat: integrate tornado cash --- .changeset/tornado-cash-integration.md | 7 + apps/api/src/clients/index.ts | 1 + apps/api/src/clients/torn/abi.ts | 44 +++ apps/api/src/clients/torn/index.ts | 201 +++++++++++ apps/api/src/lib/client.ts | 5 + apps/api/src/lib/constants.ts | 14 + apps/api/src/lib/enums.ts | 1 + apps/api/src/lib/eventRelevance.ts | 7 + apps/api/src/services/coingecko/types.ts | 2 + .../components/icons/TornadoCashIcon.tsx | 39 ++ .../shared/components/icons/index.ts | 2 + apps/dashboard/shared/dao-config/index.ts | 2 + apps/dashboard/shared/dao-config/torn.ts | 332 ++++++++++++++++++ apps/dashboard/shared/og/dao-og-icons.tsx | 27 ++ apps/dashboard/shared/types/daos.ts | 1 + apps/indexer/config/torn.config.ts | 37 ++ apps/indexer/ponder.config.ts | 3 + apps/indexer/src/index.ts | 6 + apps/indexer/src/indexer/torn/INTEGRATION.md | 113 ++++++ apps/indexer/src/indexer/torn/abi/governor.ts | 45 +++ apps/indexer/src/indexer/torn/abi/index.ts | 2 + apps/indexer/src/indexer/torn/abi/token.ts | 12 + apps/indexer/src/indexer/torn/erc20.ts | 164 +++++++++ apps/indexer/src/indexer/torn/governor.ts | 271 ++++++++++++++ apps/indexer/src/indexer/torn/index.ts | 3 + apps/indexer/src/lib/constants.ts | 27 ++ apps/indexer/src/lib/enums.ts | 1 + 27 files changed, 1369 insertions(+) create mode 100644 .changeset/tornado-cash-integration.md create mode 100644 apps/api/src/clients/torn/abi.ts create mode 100644 apps/api/src/clients/torn/index.ts create mode 100644 apps/dashboard/shared/components/icons/TornadoCashIcon.tsx create mode 100644 apps/dashboard/shared/dao-config/torn.ts create mode 100644 apps/indexer/config/torn.config.ts create mode 100644 apps/indexer/src/indexer/torn/INTEGRATION.md create mode 100644 apps/indexer/src/indexer/torn/abi/governor.ts create mode 100644 apps/indexer/src/indexer/torn/abi/index.ts create mode 100644 apps/indexer/src/indexer/torn/abi/token.ts create mode 100644 apps/indexer/src/indexer/torn/erc20.ts create mode 100644 apps/indexer/src/indexer/torn/governor.ts create mode 100644 apps/indexer/src/indexer/torn/index.ts diff --git a/.changeset/tornado-cash-integration.md b/.changeset/tornado-cash-integration.md new file mode 100644 index 000000000..d38972c71 --- /dev/null +++ b/.changeset/tornado-cash-integration.md @@ -0,0 +1,7 @@ +--- +"@anticapture/indexer": minor +"@anticapture/api": minor +"@anticapture/dashboard": minor +--- + +Integrate Tornado Cash DAO (TORN): custom stake-to-vote indexer (lock-based delegated supply, timestamp governance), timestamp-based proposal-status API client, and dashboard config/icon. diff --git a/apps/api/src/clients/index.ts b/apps/api/src/clients/index.ts index f9ab39998..66f387d9d 100644 --- a/apps/api/src/clients/index.ts +++ b/apps/api/src/clients/index.ts @@ -10,6 +10,7 @@ export * from "./uni"; export * from "./shu"; export * from "./aave"; export * from "./fluid"; +export * from "./torn"; export interface DAOClient { getDaoId: () => string; diff --git a/apps/api/src/clients/torn/abi.ts b/apps/api/src/clients/torn/abi.ts new file mode 100644 index 000000000..8d5bcc761 --- /dev/null +++ b/apps/api/src/clients/torn/abi.ts @@ -0,0 +1,44 @@ +export const TORNGovernorAbi = [ + { + type: "function", + name: "QUORUM_VOTES", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "VOTING_PERIOD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "PROPOSAL_THRESHOLD", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_DELAY", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "EXECUTION_EXPIRATION", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/apps/api/src/clients/torn/index.ts b/apps/api/src/clients/torn/index.ts new file mode 100644 index 000000000..cfe91c682 --- /dev/null +++ b/apps/api/src/clients/torn/index.ts @@ -0,0 +1,201 @@ +import { Account, Address, Chain, Client, Transport } from "viem"; + +import { DAOClient } from "@/clients"; +import { ProposalStatus } from "@/lib/constants"; + +import { GovernorBase } from "../governor.base"; + +import { TORNGovernorAbi } from "./abi"; + +const BLOCK_TIME = 12; + +export class TORNClient< + TTransport extends Transport = Transport, + TChain extends Chain = Chain, + TAccount extends Account | undefined = Account | undefined, +> + extends GovernorBase + implements DAOClient +{ + protected address: Address; + protected abi: typeof TORNGovernorAbi; + + constructor(client: Client, address: Address) { + super(client); + this.address = address; + this.abi = TORNGovernorAbi; + } + + getDaoId(): string { + return "TORN"; + } + + async getQuorum(_proposalId: string | null): Promise { + return this.getCachedQuorum(async () => { + return this.readContract({ + abi: this.abi, + address: this.address, + functionName: "QUORUM_VOTES", + }); + }); + } + + // TORN governance times everything in seconds, but the platform contract + // for getVotingDelay/getVotingPeriod expects BLOCK units (consumers multiply + // by blockTime to recover seconds). Convert seconds → blocks on read. + async getVotingDelay(): Promise { + if (!this.cache.votingDelay) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_DELAY", + })) as bigint; + this.cache.votingDelay = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingDelay!; + } + + async getVotingPeriod(): Promise { + if (!this.cache.votingPeriod) { + const seconds = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "VOTING_PERIOD", + })) as bigint; + this.cache.votingPeriod = seconds / BigInt(BLOCK_TIME); + } + return this.cache.votingPeriod!; + } + + async getProposalThreshold(): Promise { + if (!this.cache.proposalThreshold) { + this.cache.proposalThreshold = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "PROPOSAL_THRESHOLD", + })) as bigint; + } + return this.cache.proposalThreshold!; + } + + async getTimelockDelay(): Promise { + if (!this.cache.timelockDelay) { + this.cache.timelockDelay = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_DELAY", + })) as bigint; + } + return this.cache.timelockDelay!; + } + + private async getExecutionExpiration(): Promise { + if (!this.cache.executionPeriod) { + this.cache.executionPeriod = (await this.readContract({ + abi: this.abi, + address: this.address, + functionName: "EXECUTION_EXPIRATION", + })) as bigint; + } + return this.cache.executionPeriod!; + } + + calculateQuorum(votes: { + forVotes: bigint; + againstVotes: bigint; + abstainVotes: bigint; + }): bigint { + 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 32792b00a..cc8c11b7b 100644 --- a/apps/api/src/lib/client.ts +++ b/apps/api/src/lib/client.ts @@ -14,6 +14,7 @@ import { SHUClient, AAVEClient, FLUIDClient, + TORNClient, } from "@/clients"; import { CONTRACT_ADDRESSES } from "./constants"; @@ -84,6 +85,10 @@ export function getClient< case DaoIdEnum.AAVE: { return new AAVEClient(client); } + case DaoIdEnum.TORN: { + const { governor } = CONTRACT_ADDRESSES[daoId]; + return new TORNClient(client, governor.address); + } default: return null; } diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 4341f9e63..ab6ed79e3 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -220,6 +220,19 @@ export const CONTRACT_ADDRESSES = { startBlock: 12422079, }, }, + [DaoIdEnum.TORN]: { + blockTime: 12, + tokenType: "ERC20", + token: { + address: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + decimals: 18, + startBlock: 11474599, + }, + governor: { + address: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + startBlock: 11474695, + }, + }, } as const; export const TreasuryAddresses: Record> = { @@ -390,6 +403,7 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + [DaoIdEnum.TORN]: {}, }; export enum ProposalStatus { diff --git a/apps/api/src/lib/enums.ts b/apps/api/src/lib/enums.ts index b45c20d6c..025878d72 100644 --- a/apps/api/src/lib/enums.ts +++ b/apps/api/src/lib/enums.ts @@ -13,6 +13,7 @@ export enum DaoIdEnum { OBOL = "OBOL", ZK = "ZK", FLUID = "FLUID", + TORN = "TORN", } export const SECONDS_IN_DAY = 24 * 60 * 60; diff --git a/apps/api/src/lib/eventRelevance.ts b/apps/api/src/lib/eventRelevance.ts index f0599ea20..9aa8c7010 100644 --- a/apps/api/src/lib/eventRelevance.ts +++ b/apps/api/src/lib/eventRelevance.ts @@ -232,6 +232,13 @@ const DAO_RELEVANCE_THRESHOLDS: Record = { [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, }, + [DaoIdEnum.TORN]: { + [FeedEventType.TRANSFER]: EMPTY_THRESHOLDS, + [FeedEventType.DELEGATION]: EMPTY_THRESHOLDS, + [FeedEventType.VOTE]: EMPTY_THRESHOLDS, + [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, + [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, + }, }; export function getDaoRelevanceThreshold(daoId: DaoIdEnum): EventRelevanceMap { diff --git a/apps/api/src/services/coingecko/types.ts b/apps/api/src/services/coingecko/types.ts index 8bb6f7667..d8fb2fd62 100644 --- a/apps/api/src/services/coingecko/types.ts +++ b/apps/api/src/services/coingecko/types.ts @@ -26,6 +26,7 @@ export const CoingeckoTokenIdEnum: Record = { ZK: "zksync", SHU: "shutter", FLUID: "fluid", + TORN: "tornado-cash", } as const; export const CoingeckoIdToAssetPlatformId = { @@ -41,6 +42,7 @@ export const CoingeckoIdToAssetPlatformId = { [CoingeckoTokenIdEnum.ZK]: AssetPlatformEnum.ZKSYNC, [CoingeckoTokenIdEnum.SHU]: AssetPlatformEnum.ETHEREUM, [CoingeckoTokenIdEnum.FLUID]: AssetPlatformEnum.ETHEREUM, + [CoingeckoTokenIdEnum.TORN]: AssetPlatformEnum.ETHEREUM, } as const; export interface CoingeckoHistoricalMarketData { diff --git a/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx b/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx new file mode 100644 index 000000000..df0ee9df1 --- /dev/null +++ b/apps/dashboard/shared/components/icons/TornadoCashIcon.tsx @@ -0,0 +1,39 @@ +import type { DaoIconProps } from "@/shared/components/icons/types"; + +export const TornadoCashIcon = ({ + showBackground = true, + ...props +}: DaoIconProps) => { + return ( + + {showBackground && } + + + + + + + + + ); +}; diff --git a/apps/dashboard/shared/components/icons/index.ts b/apps/dashboard/shared/components/icons/index.ts index 3f71b11bc..8f592c354 100644 --- a/apps/dashboard/shared/components/icons/index.ts +++ b/apps/dashboard/shared/components/icons/index.ts @@ -15,6 +15,8 @@ export * from "@/shared/components/icons/ScrollIcon"; export * from "@/shared/components/icons/CompoundIcon"; export * from "@/shared/components/icons/ObolIcon"; export * from "@/shared/components/icons/ShutterIcon"; +export * from "@/shared/components/icons/TornadoCashIcon"; +// THE IMPORT OF DAO AVATAR ICON MUST BE LAST export * from "@/shared/components/icons/DaoAvatarIcon"; export * from "@/shared/components/icons/CookieBackground"; diff --git a/apps/dashboard/shared/dao-config/index.ts b/apps/dashboard/shared/dao-config/index.ts index e99c763b2..fd6581f7b 100644 --- a/apps/dashboard/shared/dao-config/index.ts +++ b/apps/dashboard/shared/dao-config/index.ts @@ -8,6 +8,7 @@ import { OBOL } from "@/shared/dao-config/obol"; import { OP } from "@/shared/dao-config/op"; import { SCR } from "@/shared/dao-config/scr"; import { SHU } from "@/shared/dao-config/shu"; +import { TORN } from "@/shared/dao-config/torn"; import type { DaoConfiguration } from "@/shared/dao-config/types"; import { UNI } from "@/shared/dao-config/uni"; import { AAVE } from "@/shared/dao-config/aave"; @@ -78,6 +79,7 @@ const rawDaoConfigByDaoId = { COMP, OBOL, SHU, + TORN, } as const; const daoConfigByDaoId = Object.fromEntries( diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts new file mode 100644 index 000000000..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 efff5a81d..d28167d45 100644 --- a/apps/dashboard/shared/og/dao-og-icons.tsx +++ b/apps/dashboard/shared/og/dao-og-icons.tsx @@ -278,3 +278,30 @@ export function EnsOgArt({ height, color = DEFAULT_FILL }: OgArtProps) { ); } + +export function TornadoCashOgIcon({ size, color = DEFAULT_FILL }: OgIconProps) { + return ( + + + + + + + + + + ); +} diff --git a/apps/dashboard/shared/types/daos.ts b/apps/dashboard/shared/types/daos.ts index f5ce324a0..a4b311106 100644 --- a/apps/dashboard/shared/types/daos.ts +++ b/apps/dashboard/shared/types/daos.ts @@ -11,6 +11,7 @@ export enum DaoIdEnum { // OPTIMISM = "OP", UNISWAP = "UNI", GITCOIN = "GTC", + TORN = "TORN", } export const ALL_DAOS = Object.values(DaoIdEnum); diff --git a/apps/indexer/config/torn.config.ts b/apps/indexer/config/torn.config.ts new file mode 100644 index 000000000..915c3c455 --- /dev/null +++ b/apps/indexer/config/torn.config.ts @@ -0,0 +1,37 @@ +import { createConfig } from "ponder"; + +import { CONTRACT_ADDRESSES } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; +import { env } from "@/env"; +import { TORNTokenAbi, TORNGovernorAbi } from "@/indexer/torn/abi"; + +const TORN_CONTRACTS = CONTRACT_ADDRESSES[DaoIdEnum.TORN]; + +export default createConfig({ + database: { + kind: "postgres", + connectionString: env.DATABASE_URL, + }, + chains: { + ethereum_mainnet: { + id: 1, + rpc: env.RPC_URL, + maxRequestsPerSecond: env.MAX_REQUESTS_PER_SECOND, + pollingInterval: env.POLLING_INTERVAL, + }, + }, + contracts: { + TORNToken: { + abi: TORNTokenAbi, + chain: "ethereum_mainnet", + address: TORN_CONTRACTS.token.address, + startBlock: TORN_CONTRACTS.token.startBlock, + }, + TORNGovernor: { + abi: TORNGovernorAbi, + chain: "ethereum_mainnet", + address: TORN_CONTRACTS.governor.address, + startBlock: TORN_CONTRACTS.governor.startBlock, + }, + }, +}); diff --git a/apps/indexer/ponder.config.ts b/apps/indexer/ponder.config.ts index 57cc70f94..83b226792 100644 --- a/apps/indexer/ponder.config.ts +++ b/apps/indexer/ponder.config.ts @@ -10,6 +10,7 @@ import obolConfig from "./config/obol.config"; import optimismConfig from "./config/optimism.config"; import scrollConfig from "./config/scroll.config"; import shutterConfig from "./config/shutter.config"; +import tornConfig from "./config/torn.config"; import uniswapConfig from "./config/uniswap.config"; import zkConfig from "./config/zk.config"; @@ -29,6 +30,7 @@ export default { ...obolConfig.chains, ...zkConfig.chains, ...shutterConfig.chains, + ...tornConfig.chains, }, contracts: { ...aaveConfig.contracts, @@ -45,5 +47,6 @@ export default { ...obolConfig.contracts, ...zkConfig.contracts, ...shutterConfig.contracts, + ...tornConfig.contracts, }, }; diff --git a/apps/indexer/src/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..cef25e2b2 --- /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. The insert returns `null` on conflict, and the handler short-circuits in that case so tally/count/feed updates only run for genuinely new votes — no double-counting on replay. + +### Vote tallies vs on-chain proposals struct + +Indexed vote tallies (from Voted events) may differ from on-chain `proposals(id).forVotes/againstVotes`. The governance contract was upgraded 5 times (current version: `"5.proposal-state-patch"`), including post-attack recovery proposals that may have directly modified the `proposals` mapping. Our indexed data reflects the immutable Voted event history. Both are valid — events show what happened, on-chain struct shows the current governance state. + +### Verification results (March 2026) + +Full backfill completed successfully with zero crashes. + +| Metric | Indexed | On-chain | Status | +| ------------------ | ------------ | ------------ | --------------- | +| Proposals | 65 | 65 | Exact match | +| Executed proposals | 49 | 49 | Exact match | +| Votes | 1,089 | — | Zero duplicates | +| delegatedSupply | 4,732,271.91 | 4,732,271.91 | Exact match | +| Delegations | 72 | — | — | +| Transfers | 458,282 | — | — | +| Accounts | 42,890 | — | — | diff --git a/apps/indexer/src/indexer/torn/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..bb5714305 --- /dev/null +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -0,0 +1,164 @@ +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 { + 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); + } + }); +} diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts new file mode 100644 index 000000000..281784cbe --- /dev/null +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -0,0 +1,271 @@ +import { ponder } from "ponder:registry"; +import { + accountBalance, + accountPower, + feedEvent, + proposalsOnchain, + votesOnchain, +} from "ponder:schema"; +import { getAddress } from "viem"; + +import { delegateChanged, updateProposalStatus } from "@/eventHandlers"; +import { ensureAccountExists } from "@/eventHandlers/shared"; +import { CONTRACT_ADDRESSES, ProposalStatus } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +const MAX_TITLE_LENGTH = 200; + +/** + * Extracts a proposal title from a markdown description. + * + * Strategy: + * 1. Normalize literal `\n` sequences to real newlines (some proposers + * submit descriptions with escaped newlines). + * 2. If the first non-empty line is an H1 (`# Title`), use it. + * 3. Otherwise, use the first non-empty line that is not a section header + * (H2+), truncated to MAX_TITLE_LENGTH characters. + */ +function parseProposalTitle(description: string): string { + // Try JSON first — some Tornado proposals use {"title":"...","description":"..."} + try { + const parsed = JSON.parse(description) as { + title?: string; + description?: string; + }; + if (parsed.title) return parsed.title; + } catch { + // Not JSON, continue with markdown parsing + } + + // Normalize literal "\n" (two chars) into real newlines + const normalized = description.replace(/\\n/g, "\n"); + const lines = normalized.split("\n"); + + // Pass 1: look for an H1 among leading lines (before any content) + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^# /.test(trimmed)) { + return trimmed.replace(/^# +/, ""); + } + break; // stop at first non-empty, non-H1 line + } + + // Pass 2: no H1 found — use first non-empty, non-header line + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || /^#{1,6}\s/.test(trimmed)) continue; + return trimmed.length > MAX_TITLE_LENGTH + ? trimmed.substring(0, MAX_TITLE_LENGTH) + "..." + : trimmed; + } + + return ""; +} + +/** + * Custom governance indexer for Tornado Cash DAO. + * + * Key differences from standard governors: + * - ProposalCreated uses timestamps (startTime/endTime) instead of block numbers + * - Voted event uses bool support instead of uint8 + * - Delegation happens through the governance contract (Delegated/Undelegated events) + */ +export function TORNGovernorIndexer(blockTime: number) { + const daoId = DaoIdEnum.TORN; + const TORN_TOKEN_ADDRESS = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].token.address, + ); + + ponder.on("TORNGovernor:ProposalCreated", async ({ event, context }) => { + const { id, proposer, target, startTime, endTime, description } = + event.args; + const proposalIdStr = id.toString(); + + await ensureAccountExists(context, proposer); + + const title = parseProposalTitle(description); + + // Convert timestamps to synthetic block numbers for schema compat + const startBlock = + Number(event.block.number) + + Math.floor( + (Number(startTime) - Number(event.block.timestamp)) / blockTime, + ); + const endBlock = + Number(event.block.number) + + Math.floor((Number(endTime) - Number(event.block.timestamp)) / blockTime); + + await context.db.insert(proposalsOnchain).values({ + id: proposalIdStr, + txHash: event.transaction.hash, + daoId, + proposerAccountId: getAddress(proposer), + targets: [getAddress(target)], + values: [], + signatures: [], + calldatas: [], + startBlock, + endBlock, + title, + description, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + // Non-zero voting delay means a proposal can be created before its + // voting window opens — persist PENDING so status-filtered queries + // don't miss it during the pre-vote window. + status: + event.block.timestamp < startTime + ? ProposalStatus.PENDING + : ProposalStatus.ACTIVE, + endTimestamp: endTime, + }); + + const { votingPower: proposerVotingPower } = await context.db + .insert(accountPower) + .values({ + accountId: getAddress(proposer), + daoId, + proposalsCount: 1, + }) + .onConflictDoUpdate((current) => ({ + proposalsCount: current.proposalsCount + 1, + })); + + await context.db.insert(feedEvent).values({ + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + type: "PROPOSAL", + timestamp: event.block.timestamp, + metadata: { + id: proposalIdStr, + proposer: getAddress(proposer), + votingPower: proposerVotingPower, + title, + }, + }); + }); + + /** + * Voted — custom handler using onConflictDoNothing to prevent Ponder's + * DelayedInsertError crash during batch flushing. Ponder's deferred insert + * mechanism can hit unique constraint violations during backfill; plain + * inserts (as in the shared voteCast) cause unhandledRejection crashes. + * + * bool support mapped: true→1 (for), false→0 (against). No reason field. + */ + ponder.on("TORNGovernor:Voted", async ({ event, context }) => { + const { proposalId, voter, support, votes } = event.args; + const proposalIdStr = proposalId.toString(); + const supportNum = support ? 1 : 0; + const normalizedVoter = getAddress(voter); + + await ensureAccountExists(context, voter); + + // onConflictDoNothing prevents DelayedInsertError from Ponder's batch flush. + // Returns null on conflict (duplicate vote during replay/backfill) — in that + // case skip all downstream updates so tallies/counts aren't double-counted. + const inserted = await context.db + .insert(votesOnchain) + .values({ + txHash: event.transaction.hash, + daoId, + proposalId: proposalIdStr, + voterAccountId: normalizedVoter, + support: supportNum.toString(), + votingPower: votes, + reason: "", + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }) + .onConflictDoNothing(); + + if (!inserted) return; + + await context.db + .insert(accountPower) + .values({ + accountId: normalizedVoter, + daoId, + votesCount: 1, + lastVoteTimestamp: event.block.timestamp, + }) + .onConflictDoUpdate((current) => ({ + votesCount: current.votesCount + 1, + lastVoteTimestamp: event.block.timestamp, + })); + + await context.db + .update(proposalsOnchain, { id: proposalIdStr }) + .set((current) => ({ + againstVotes: current.againstVotes + (supportNum === 0 ? votes : 0n), + forVotes: current.forVotes + (supportNum === 1 ? votes : 0n), + })); + + const proposal = await context.db.find(proposalsOnchain, { + id: proposalIdStr, + }); + + await context.db.insert(feedEvent).values({ + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + type: "VOTE", + value: votes, + timestamp: event.block.timestamp, + metadata: { + voter: normalizedVoter, + reason: "", + support: supportNum, + votingPower: votes, + proposalId: proposalIdStr, + title: proposal?.title ?? undefined, + }, + }); + }); + + ponder.on("TORNGovernor:ProposalExecuted", async ({ event, context }) => { + await updateProposalStatus( + context, + event.args.proposalId.toString(), + ProposalStatus.EXECUTED, + event.block.timestamp, + event.transaction.hash, + ); + }); + + ponder.on("TORNGovernor:Delegated", async ({ event, context }) => { + const { account, to } = event.args; + + // Look up the previous delegate from accountBalance + const existing = await context.db.find(accountBalance, { + accountId: getAddress(account), + tokenId: TORN_TOKEN_ADDRESS, + }); + const previousDelegate = existing?.delegate ?? getAddress(account); + + await delegateChanged(context, daoId, { + delegator: account, + delegate: to, + tokenId: TORN_TOKEN_ADDRESS, + previousDelegate, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); + + ponder.on("TORNGovernor:Undelegated", async ({ event, context }) => { + const { account, from } = event.args; + + // Undelegation: delegate reverts to self, previous delegate was `from` + await delegateChanged(context, daoId, { + delegator: account, + delegate: account, + tokenId: TORN_TOKEN_ADDRESS, + previousDelegate: from, + txHash: event.transaction.hash, + timestamp: event.block.timestamp, + logIndex: event.log.logIndex, + }); + }); +} diff --git a/apps/indexer/src/indexer/torn/index.ts b/apps/indexer/src/indexer/torn/index.ts new file mode 100644 index 000000000..c2689388e --- /dev/null +++ b/apps/indexer/src/indexer/torn/index.ts @@ -0,0 +1,3 @@ +export { TORNTokenAbi, TORNGovernorAbi } from "./abi"; +export { TORNTokenIndexer } from "./erc20"; +export { TORNGovernorIndexer } from "./governor"; diff --git a/apps/indexer/src/lib/constants.ts b/apps/indexer/src/lib/constants.ts index 0562ff8fa..55f51d0eb 100644 --- a/apps/indexer/src/lib/constants.ts +++ b/apps/indexer/src/lib/constants.ts @@ -230,6 +230,20 @@ export const CONTRACT_ADDRESSES = { address: "0xA700b4eB416Be35b2911fd5Dee80678ff64fF6C9", }, }, + [DaoIdEnum.TORN]: { + blockTime: 12, + // https://etherscan.io/address/0x77777FeDdddFfC19Ff86DB637967013e6C6A116C + token: { + address: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + decimals: 18, + startBlock: 11474599, + }, + // https://etherscan.io/address/0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce + governor: { + address: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + startBlock: 11474695, + }, + }, } as const; export const TreasuryAddresses: Record> = { @@ -403,6 +417,7 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + [DaoIdEnum.TORN]: {}, }; export const CEXAddresses: Record> = { @@ -622,6 +637,7 @@ export const CEXAddresses: Record> = { Gate: "0x0D0707963952f2fBA59dD06f2b425ace40b492Fe", Bitvavo: "0xaB782bc7D4a2b306825de5a7730034F8F63ee1bC", }, + [DaoIdEnum.TORN]: {}, }; export const DEXAddresses: Record> = { @@ -695,6 +711,7 @@ export const DEXAddresses: Record> = { [DaoIdEnum.FLUID]: { "Uniswap V3 INST/WETH": "0xc1cd3D0913f4633b43FcdDBCd7342bC9b71C676f", }, + [DaoIdEnum.TORN]: {}, }; export const LendingAddresses: Record> = { @@ -745,6 +762,7 @@ export const LendingAddresses: Record> = { }, [DaoIdEnum.SHU]: {}, [DaoIdEnum.FLUID]: {}, + [DaoIdEnum.TORN]: {}, }; export const BurningAddresses: Record< @@ -832,6 +850,11 @@ export const BurningAddresses: Record< Dead: "0x000000000000000000000000000000000000dEaD", TokenContract: "0x6f40d4A6237C257fff2dB00FA0510DeEECd303eb", }, + [DaoIdEnum.TORN]: { + ZeroAddress: zeroAddress, + Dead: "0x000000000000000000000000000000000000dEaD", + TokenContract: "0x77777FeDdddFfC19Ff86DB637967013e6C6A116C", + }, }; export const NonCirculatingAddresses: Record< @@ -872,6 +895,10 @@ export const NonCirculatingAddresses: Record< }, [DaoIdEnum.LIL_NOUNS]: {}, [DaoIdEnum.SHU]: {}, + [DaoIdEnum.TORN]: { + governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", + }, }; export enum ProposalStatus { diff --git a/apps/indexer/src/lib/enums.ts b/apps/indexer/src/lib/enums.ts index 3580fd6f3..c558794b7 100644 --- a/apps/indexer/src/lib/enums.ts +++ b/apps/indexer/src/lib/enums.ts @@ -14,6 +14,7 @@ export enum DaoIdEnum { SHU = "SHU", FLUID = "FLUID", LIL_NOUNS = "LIL_NOUNS", + TORN = "TORN", } export const SECONDS_IN_DAY = 24 * 60 * 60; From f542b75c471519fd3a23ca2fdba7764c1ff77656 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Mon, 29 Jun 2026 11:50:50 -0300 Subject: [PATCH 02/44] chore: enalbe offchain api returning 400 for the not-supporte ones --- apps/api/cmd/index.ts | 48 +++++++++---------- .../proposals/offchainProposals.ts | 16 +++++++ .../controllers/votes/offchainNonVoters.ts | 16 +++++++ .../src/controllers/votes/offchainVotes.ts | 22 ++++++++- apps/dashboard/shared/dao-config/torn.ts | 1 + scripts/dev.sh | 30 +++--------- 6 files changed, 83 insertions(+), 50 deletions(-) diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index 390fd73b8..a97f4f5f3 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -181,7 +181,7 @@ if (!daoClient) { } const pgClient = drizzle(env.DATABASE_URL, { - schema, + schema: { ...schema, ...offchainSchema }, casing: "snake_case", }); @@ -396,32 +396,28 @@ if (env.DAO_ID === DaoIdEnum.ENS) { revenue(app, revenueDuneClient); } -if (daoClient.supportOffchainData()) { - const pgUnifiedClient = drizzle(env.DATABASE_URL, { - schema: { ...schema, ...offchainSchema }, - casing: "snake_case", - }); - - const offchainProposalsRepo = wrapWithTracing( - new OffchainProposalRepository(pgUnifiedClient), - ); - const offchainVotesRepo = wrapWithTracing( - new OffchainVoteRepository(pgUnifiedClient), - ); - offchainProposals( - app, - wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), - ); - offchainVotes( - app, - wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), - ); +const supportOffchain = daoClient.supportOffchainData(); +const offchainProposalsRepo = wrapWithTracing( + new OffchainProposalRepository(pgClient), +); +const offchainVotesRepo = wrapWithTracing(new OffchainVoteRepository(pgClient)); +offchainProposals( + app, + wrapWithTracing(new OffchainProposalsService(offchainProposalsRepo)), + supportOffchain, +); +offchainVotes( + app, + wrapWithTracing(new OffchainVotesService(offchainVotesRepo)), + supportOffchain, +); - const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl( - pgUnifiedClient, - ); - offchainNonVoters(app, new OffchainNonVotersService(offchainNonVotersRepo)); -} +const offchainNonVotersRepo = new OffchainNonVotersRepositoryImpl(pgClient); +offchainNonVoters( + app, + wrapWithTracing(new OffchainNonVotersService(offchainNonVotersRepo)), + supportOffchain, +); serve( { diff --git a/apps/api/src/controllers/proposals/offchainProposals.ts b/apps/api/src/controllers/proposals/offchainProposals.ts index 90d1925d1..f63866f48 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.ts @@ -16,6 +16,7 @@ import { OffchainProposalsService } from "@/services"; export function offchainProposals( app: Hono, service: OffchainProposalsService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -39,9 +40,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, orderDirection, status, fromDate, endDate, lean } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainNonVoters.ts b/apps/api/src/controllers/votes/offchainNonVoters.ts index f7a101215..d2d59ab6f 100644 --- a/apps/api/src/controllers/votes/offchainNonVoters.ts +++ b/apps/api/src/controllers/votes/offchainNonVoters.ts @@ -10,6 +10,7 @@ import { OffchainNonVotersService } from "@/services"; export function offchainNonVoters( app: Hono, service: OffchainNonVotersService, + supportOffchain: boolean, ) { app.openapi( createRoute({ @@ -35,6 +36,14 @@ export function offchainNonVoters( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, 404: { description: "Proposal not found", content: { @@ -46,6 +55,13 @@ export function offchainNonVoters( }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { skip, limit, orderDirection, addresses } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainVotes.ts b/apps/api/src/controllers/votes/offchainVotes.ts index a8e990deb..6926c8ff4 100644 --- a/apps/api/src/controllers/votes/offchainVotes.ts +++ b/apps/api/src/controllers/votes/offchainVotes.ts @@ -1,6 +1,7 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; import { + ErrorResponseSchema, OffchainProposalRequestSchema, OffchainVotesRequestSchema, OffchainVotesResponseSchema, @@ -8,7 +9,11 @@ import { import { setCacheControl } from "@/middlewares"; import { OffchainVotesService } from "@/services"; -export function offchainVotes(app: Hono, service: OffchainVotesService) { +export function offchainVotes( + app: Hono, + service: OffchainVotesService, + supportOffchain: boolean, +) { app.openapi( createRoute({ method: "get", @@ -30,9 +35,24 @@ export function offchainVotes(app: Hono, service: OffchainVotesService) { }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { skip, limit, diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index 71468c72b..4abfeec66 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -328,5 +328,6 @@ export const TORN: DaoConfiguration = { resilienceStages: true, tokenDistribution: true, dataTables: true, + overviewPage: false, governancePage: true, }; diff --git a/scripts/dev.sh b/scripts/dev.sh index 13b139357..a2556ed29 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -31,6 +31,7 @@ dao_id_for() { uniswap) echo "uni" ;; gitcoin) echo "gtc" ;; scroll) echo "scr" ;; + tornado) echo "torn" ;; shutter) echo "shu" ;; compound) echo "comp" ;; *) echo "$1" ;; @@ -132,19 +133,9 @@ start_gateful() { } start_relayer() { - if [ -z "$DAO_NAME" ]; then - log "Skipping optional Relayer (no DAO selected)" - return 1 - fi - local service="$(echo "$DAO_NAME" | tr '[:upper:]' '[:lower:]')-relayer" - log "Starting optional Relayer ($service)..." - # Always run through `railway run`; if the service doesn't exist it simply - # fails to come up and wait_for_optional_port lets the stack continue. - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s "$service" pnpm relayer dev & - if wait_for_optional_port "$PORT_RELAYER" "Relayer"; then - return 0 - fi - return 1 + export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & + wait_for_port "$PORT_RELAYER" "Relayer" } if [ "${BASH_SOURCE[0]}" != "$0" ]; then @@ -267,11 +258,8 @@ if [ "$RUN_API" = true ]; then ) & fi -# 5. Relayer (optional; only available for a few DAOs — do not block the rest of the stack) -RELAYER_AVAILABLE=false -if start_relayer; then - RELAYER_AVAILABLE=true -fi +# 5. Relayer (only available for ENS) +start_relayer # 6. Gateful start_gateful @@ -299,11 +287,7 @@ else printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" fi printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" -if [ "$RELAYER_AVAILABLE" = true ]; then - printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" -else - printf " ${C_RELAYER}📡 Relayer${C_RESET} skipped (optional)\n" -fi +printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" echo "" From 2a69466b3d5c4e2e2466c69481db6e644d1891fe Mon Sep 17 00:00:00 2001 From: zeugh Date: Mon, 29 Jun 2026 19:14:56 -0300 Subject: [PATCH 03/44] fix(torn): correct quorum calc + governance-config facts (review follow-up) - TORNClient.calculateQuorum: count forVotes + againstVotes (matches the on-chain Governance.state() quorum rule); update status docstring. - dao-config: proposal threshold 1,000 TORN (was 25,000); voting delay 75s (was ~1 block/12s); changeVote=true (re-voting overwrites the prior receipt on-chain). Refs review on #2002. --- apps/api/src/clients/torn/index.ts | 6 ++++-- apps/dashboard/shared/dao-config/torn.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/src/clients/torn/index.ts b/apps/api/src/clients/torn/index.ts index cfe91c682..8658409dc 100644 --- a/apps/api/src/clients/torn/index.ts +++ b/apps/api/src/clients/torn/index.ts @@ -105,7 +105,9 @@ export class TORNClient< againstVotes: bigint; abstainVotes: bigint; }): bigint { - return votes.forVotes; + // Tornado's Governance.state() reaches quorum on forVotes + againstVotes + // (both sides count toward QUORUM_VOTES), not forVotes alone. + return votes.forVotes + votes.againstVotes; } alreadySupportCalldataReview(): boolean { @@ -123,7 +125,7 @@ export class TORNClient< * EXECUTED → finalized (persisted by indexer) * now < startTime → PENDING * now < endTimestamp → ACTIVE - * forVotes < quorum → NO_QUORUM + * forVotes + againstVotes < quorum → NO_QUORUM * forVotes <= againstVotes → DEFEATED * now < endTimestamp + EXECUTION_DELAY → QUEUED * now < endTimestamp + EXECUTION_DELAY + EXECUTION_EXPIRATION → PENDING_EXECUTION diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index 4abfeec66..c6a46acbe 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -30,7 +30,7 @@ export const TORN: DaoConfiguration = { }, rules: { delay: true, - changeVote: false, + changeVote: true, timelock: true, cancelFunction: false, logic: "For", @@ -113,7 +113,7 @@ export const TORN: DaoConfiguration = { GOVERNANCE_IMPLEMENTATION_CONSTANTS[ GovernanceImplementationEnum.PROPOSAL_THRESHOLD ].description, - currentSetting: "The Proposal Threshold is set to 25,000 TORN.", + currentSetting: "The Proposal Threshold is set to 1,000 TORN.", impact: "Tornado Cash has a proposal threshold that adds a cost barrier to submitting proposals, but it may not be high enough relative to circulating supply to fully deter spam.", recommendedSetting: @@ -235,7 +235,7 @@ export const TORN: DaoConfiguration = { GovernanceImplementationEnum.VOTING_DELAY ].description, currentSetting: - "The Voting Delay is set to approximately 1 block (~12 seconds).", + "The Voting Delay is set to 75 seconds.", impact: "The Voting Delay period is extremely short. This gives delegates and stakeholders little time to coordinate their votes and for the DAO to protect itself against an attack. This poses a critical governance risk.", recommendedSetting: From 4e60b18e94bbe771c370650e1d3546c266b08269 Mon Sep 17 00:00:00 2001 From: zeugh Date: Mon, 29 Jun 2026 19:29:34 -0300 Subject: [PATCH 04/44] fix(torn): reconcile dashboard with quorum + re-vote changes (Codex) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rules.logic: "All Votes Cast" (was "For") to match calculateQuorum now counting forVotes + againstVotes — dashboard no longer explains the opposite quorum rule. - VOTE_MUTABILITY: LOW + copy reflecting that TORN allows changing votes (consistent with rules.changeVote=true); drop stale requirements. - attackExposure GOV_FRONTEND_RESILIENCE: remove the 'immutable votes' claim (votes are mutable) — keeps the unhardened-interface point. --- apps/dashboard/shared/dao-config/torn.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index c6a46acbe..8ba41d7a8 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -33,7 +33,7 @@ export const TORN: DaoConfiguration = { changeVote: true, timelock: true, cancelFunction: false, - logic: "For", + logic: "All Votes Cast", quorumCalculation: "Fixed at 100,000 TORN", }, }, @@ -210,23 +210,18 @@ export const TORN: DaoConfiguration = { "A veto mechanism or Security Council should be established to protect against malicious proposals.", }, [GovernanceImplementationEnum.VOTE_MUTABILITY]: { - riskLevel: RiskLevel.MEDIUM, + riskLevel: RiskLevel.LOW, description: GOVERNANCE_IMPLEMENTATION_CONSTANTS[ GovernanceImplementationEnum.VOTE_MUTABILITY ].description, currentSetting: - "The DAO does not allow changing votes once they have been cast.", + "The DAO allows voters to change their vote while a proposal is active; re-casting overwrites the prior ballot.", 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.", + "Voters can revise their vote until the voting period ends — e.g. to revert a ballot if a malicious proposal or an interface compromise is discovered.", recommendedSetting: RECOMMENDED_SETTINGS[GovernanceImplementationEnum.VOTE_MUTABILITY], - nextStep: - "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.", - ], + nextStep: "The parameter is in its lowest-risk condition.", }, [GovernanceImplementationEnum.VOTING_DELAY]: { riskLevel: RiskLevel.HIGH, @@ -321,7 +316,7 @@ export const TORN: DaoConfiguration = { }, [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.", + "Interface protections are present but not fully hardened (e.g. not served from immutable/decentralized storage), resulting in moderate governance interface risk. Mutable votes do allow recovery if a front-end compromise is caught during the voting window.", }, }, }, From d94e2a0ac5f0ec1f04b941c904b2988283b72393 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Mon, 29 Jun 2026 21:49:06 -0300 Subject: [PATCH 05/44] fix: vp based on custody transfers --- apps/dashboard/shared/dao-config/torn.ts | 2 +- apps/indexer/src/indexer/torn/abi/governor.ts | 138 +++++++++++++++--- apps/indexer/src/indexer/torn/erc20.ts | 54 ++++++- 3 files changed, 170 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index 4abfeec66..aba97ac86 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -325,7 +325,7 @@ export const TORN: DaoConfiguration = { }, }, }, - resilienceStages: true, + resilienceStages: false, tokenDistribution: true, dataTables: true, overviewPage: false, diff --git a/apps/indexer/src/indexer/torn/abi/governor.ts b/apps/indexer/src/indexer/torn/abi/governor.ts index 267a63096..1ba5784d4 100644 --- a/apps/indexer/src/indexer/torn/abi/governor.ts +++ b/apps/indexer/src/indexer/torn/abi/governor.ts @@ -1,45 +1,139 @@ export const TORNGovernorAbi = [ { - type: "event", - name: "ProposalCreated", + anonymous: false, 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" }, + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { indexed: true, internalType: "address", name: "to", type: "address" }, ], + name: "Delegated", + type: "event", }, { - type: "event", - name: "Voted", + anonymous: false, 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" }, + { indexed: true, internalType: "uint256", name: "id", type: "uint256" }, + { + indexed: true, + internalType: "address", + name: "proposer", + type: "address", + }, + { + indexed: false, + internalType: "address", + name: "target", + type: "address", + }, + { + indexed: false, + internalType: "uint256", + name: "startTime", + type: "uint256", + }, + { + indexed: false, + internalType: "uint256", + name: "endTime", + type: "uint256", + }, + { + indexed: false, + internalType: "string", + name: "description", + type: "string", + }, ], + name: "ProposalCreated", + type: "event", }, { - type: "event", + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "uint256", + name: "proposalId", + type: "uint256", + }, + ], name: "ProposalExecuted", - inputs: [{ indexed: true, name: "proposalId", type: "uint256" }], + type: "event", }, { - type: "event", - name: "Delegated", + anonymous: false, inputs: [ - { indexed: true, name: "account", type: "address" }, - { indexed: true, name: "to", type: "address" }, + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { + indexed: true, + internalType: "bytes", + name: "errorData", + type: "bytes", + }, ], + name: "RewardUpdateFailed", + type: "event", }, { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + ], + name: "RewardUpdateSuccessful", type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "account", + type: "address", + }, + { indexed: true, internalType: "address", name: "from", type: "address" }, + ], name: "Undelegated", + type: "event", + }, + { + anonymous: false, inputs: [ - { indexed: true, name: "account", type: "address" }, - { indexed: true, name: "from", type: "address" }, + { + indexed: true, + internalType: "uint256", + name: "proposalId", + type: "uint256", + }, + { + indexed: true, + internalType: "address", + name: "voter", + type: "address", + }, + { indexed: true, internalType: "bool", name: "support", type: "bool" }, + { + indexed: false, + internalType: "uint256", + name: "votes", + type: "uint256", + }, ], + name: "Voted", + type: "event", }, ] as const; diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts index bb5714305..1fc0c54cf 100644 --- a/apps/indexer/src/indexer/torn/erc20.ts +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -1,8 +1,9 @@ import { ponder } from "ponder:registry"; -import { token } from "ponder:schema"; +import { accountPower, token, votingPowerHistory } from "ponder:schema"; import { Address, getAddress } from "viem"; import { tokenTransfer } from "@/eventHandlers"; +import { ensureAccountExists } from "@/eventHandlers/shared"; import { updateDelegatedSupply, updateCirculatingSupply, @@ -27,6 +28,16 @@ export function TORNTokenIndexer(address: Address, decimals: number) { CONTRACT_ADDRESSES[DaoIdEnum.TORN].governor.address, ); + // Contracts that custody locked TORN. A balance held here is voting power + // owned by the locker, not the custody contract. TORN emits no + // DelegateVotesChanged, so per-account voting power is derived from + // lock/unlock Transfers in/out of these addresses. Add new lock contracts here. + const lockCustodyAddresses = new Set
( + Object.values(NonCirculatingAddresses[daoId]).map((addr) => + getAddress(addr), + ), + ); + ponder.on("TORNToken:setup", async ({ context }) => { await context.db.insert(token).values({ id: address, @@ -160,5 +171,46 @@ export function TORNTokenIndexer(address: Address, decimals: number) { // Unlocking TORN from governance await updateDelegatedSupply(context, daoId, address, -value, timestamp); } + + // Per-account voting power. A transfer into custody locks (the locker is + // `from`, gaining power); out of custody unlocks (the locker is `to`, + // losing power). Governor<->vault internal moves (e.g. the vault migration) + // have custody on both sides and net to zero, so they are skipped. + const toIsCustody = lockCustodyAddresses.has(normalizedTo); + const fromIsCustody = lockCustodyAddresses.has(normalizedFrom); + + if (toIsCustody !== fromIsCustody) { + const locker = toIsCustody ? normalizedFrom : normalizedTo; + const delta = toIsCustody ? value : -value; + + await ensureAccountExists(context, locker); + + const { votingPower } = await context.db + .insert(accountPower) + .values({ accountId: locker, daoId, votingPower: delta }) + .onConflictDoUpdate((current) => ({ + votingPower: current.votingPower + delta, + })); + + await context.db + .insert(votingPowerHistory) + .values({ + daoId, + transactionHash: event.transaction.hash, + accountId: locker, + votingPower, + delta, + deltaMod: delta > 0n ? delta : -delta, + timestamp, + logIndex: event.log.logIndex, + }) + .onConflictDoNothing(); + } }); + + // Voting power is derived from lock/unlock Transfers (see handler above), not + // from these reward events — they carry only `account`, no amount. Kept as + // no-ops so the configured events have handlers. + // ponder.on("TORNGovernor:RewardUpdateSuccessful", async () => {}); + // ponder.on("TORNGovernor:RewardUpdateFailed", async () => {}); } From cbd82c1daad46a4cf29a1c60e1e2611b37ba58db Mon Sep 17 00:00:00 2001 From: zeugh Date: Mon, 29 Jun 2026 23:08:22 -0300 Subject: [PATCH 06/44] =?UTF-8?q?feat(torn):=20custom-governor=20write=20a?= =?UTF-8?q?ctions=20=E2=80=94=20vote=20+=20delegate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the SHU custom-governor pattern so Tornado's write flows work: - voteOnProposal: add tornVoteHandler -> castVote(uint256, bool) on the governor (for=true/against=false, no abstain); route via getVoteHandler. - delegateTo: route TORN delegation to the governor's delegate(address) (delegatedTo mapping), not the ERC20Votes token. Verified on a mainnet fork (forge): castVote(bool) + governor.delegate succeed; the old OZ uint8/token-delegate paths revert. Proposal creation left ENS-gated (TORN's contract-as-proposal model needs its own form). --- .../governance/utils/voteOnProposal.ts | 38 +++++++++++++++++++ .../delegate/utils/delegateTo.ts | 12 +++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.ts b/apps/dashboard/features/governance/utils/voteOnProposal.ts index a24b7862a..74a06790e 100644 --- a/apps/dashboard/features/governance/utils/voteOnProposal.ts +++ b/apps/dashboard/features/governance/utils/voteOnProposal.ts @@ -83,6 +83,42 @@ const azoriusVoteHandler = return client.writeContract(request); }; +const TornGovernorVoteAbi = [ + { + inputs: [ + { internalType: "uint256", name: "proposalId", type: "uint256" }, + { internalType: "bool", name: "support", type: "bool" }, + ], + name: "castVote", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + +/** + * Tornado Cash (custom stake-to-vote governor): castVote(uint256, bool) on the + * governor. Binary voting — for=true / against=false, no abstain, no reason. + * Voting power = lockedBalance, so the caller must have locked TORN. + */ +const tornVoteHandler = + (daoId: DaoIdEnum): VoteHandler => + async (client, params) => { + const address = daoConfigByDaoId[daoId].daoOverview.contracts.governor; + if (!address) throw new Error("DAO governance address not found"); + if (params.voteNumber === 2) + throw new Error("Tornado Cash does not support abstain votes"); + + const { request } = await client.simulateContract({ + abi: TornGovernorVoteAbi, + address, + functionName: "castVote", + args: [BigInt(params.proposalId), params.voteNumber === 1], + account: params.account, + }); + return client.writeContract(request); + }; + /** * OZ Governor: castVote / castVoteWithReason on governor contract. */ @@ -171,6 +207,8 @@ function getVoteHandler( switch (daoId) { case DaoIdEnum.SHU: return azoriusVoteHandler(daoId); + case DaoIdEnum.TORN: + return tornVoteHandler(daoId); default: return ozGovernorVoteHandler(daoId); } diff --git a/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts b/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts index a1a6d5cfd..009dcd71f 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts +++ b/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts @@ -11,7 +11,7 @@ import { relayDelegate } from "@anticapture/client"; import type { RelayDelegatePathParamsDaoEnumKey } from "@anticapture/client"; import daoConfigByDaoId from "@/shared/dao-config"; -import type { DaoIdEnum } from "@/shared/types/daos"; +import { DaoIdEnum } from "@/shared/types/daos"; const ERC20VotesAbi = [ { @@ -141,9 +141,17 @@ export const delegateTo = async ( ); } + // Tornado Cash delegates on the governor contract (its `delegatedTo` mapping), + // not on an ERC20Votes token. Same `delegate(address)` signature, different target. + const delegationTarget = + daoId === DaoIdEnum.TORN + ? ((daoConfigByDaoId[daoId].daoOverview.contracts.governor ?? + tokenAddress) as Address) + : tokenAddress; + const { request } = await client.simulateContract({ abi: ERC20VotesAbi, - address: tokenAddress, + address: delegationTarget, functionName: "delegate", args: [delegateAddress], account, From 3086165e2a975485c8ba6de394a0f19fb2ed645f Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 09:45:15 -0300 Subject: [PATCH 07/44] fix(indexer): add torn treasury address --- apps/api/src/lib/constants.ts | 4 +++- apps/indexer/src/lib/constants.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index ab6ed79e3..801a06636 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -403,7 +403,9 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, - [DaoIdEnum.TORN]: {}, + [DaoIdEnum.TORN]: { + Governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + }, }; export enum ProposalStatus { diff --git a/apps/indexer/src/lib/constants.ts b/apps/indexer/src/lib/constants.ts index 55f51d0eb..5978be530 100644 --- a/apps/indexer/src/lib/constants.ts +++ b/apps/indexer/src/lib/constants.ts @@ -417,7 +417,9 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, - [DaoIdEnum.TORN]: {}, + [DaoIdEnum.TORN]: { + Governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + }, }; export const CEXAddresses: Record> = { From 5cc2176b6cff5b5edf8dbdd6b0a299ecb64323e0 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 09:45:43 -0300 Subject: [PATCH 08/44] fix(indexer): title and description parsing --- apps/indexer/src/indexer/torn/governor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts index 281784cbe..898754ac5 100644 --- a/apps/indexer/src/indexer/torn/governor.ts +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -34,7 +34,14 @@ function parseProposalTitle(description: string): string { }; if (parsed.title) return parsed.title; } catch { - // Not JSON, continue with markdown parsing + // Malformed pseudo-JSON is common: single quotes, missing comma between + // keys, or unescaped chars deeper in the description that break a full + // parse even though the title key itself is fine. Pull the title out + // directly before falling back to markdown. + const m = description.match( + /["']title["']\s*:\s*["']([\s\S]*?)["']\s*[,}]?\s*(?:["']description["']|$)/i, + ); + if (m?.[1]) return m[1]; } // Normalize literal "\n" (two chars) into real newlines From db66cd3b7880584b90735bef3e4a9af2a1fb1811 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 09:51:32 -0300 Subject: [PATCH 09/44] fix(dashboard): overview resiliance and attack exposure individually gated --- .../dao-overview/DaoOverviewSection.tsx | 59 +++++++++++++------ apps/dashboard/shared/dao-config/torn.ts | 1 - 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx b/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx index 67b9dd79b..2ec07c2b7 100644 --- a/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx +++ b/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx @@ -25,6 +25,7 @@ import { DaoAvatarIcon } from "@/shared/components/icons"; import daoConfigByDaoId from "@/shared/dao-config"; import { Stage } from "@/shared/types/enums/Stage"; import { BannerAlert } from "@/shared/components/design-system/alerts/banner-alert/BannerAlert"; +import { BlankSlate } from "@/shared/components/design-system/blank-slate/BlankSlate"; import { fieldsToArray, getDaoStageFromFields, @@ -102,31 +103,51 @@ export const DaoOverviewSection = ({ daoId }: { daoId: DaoIdEnum }) => {
- }> - }> + + + ) : ( + - + )}
- }> - { - router.push(`/${daoId.toLowerCase()}/risk-analysis`); - }} - variant={RiskAreaCardEnum.DAO_OVERVIEW} - className="grid h-full grid-cols-2 gap-2 px-5 lg:px-0" - currentDaoStage={currentDaoStage} + {daoConfig.attackExposure ? ( + }> + { + router.push(`/${daoId.toLowerCase()}/risk-analysis`); + }} + variant={RiskAreaCardEnum.DAO_OVERVIEW} + className="grid h-full grid-cols-2 gap-2 px-5 lg:px-0" + currentDaoStage={currentDaoStage} + /> + + ) : ( + - + )}
diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index aba97ac86..b61ce9334 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -328,6 +328,5 @@ export const TORN: DaoConfiguration = { resilienceStages: false, tokenDistribution: true, dataTables: true, - overviewPage: false, governancePage: true, }; From d8cdb61a5a12e3bd5a1ba8960e0cd1db442ccdda Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 10:57:21 -0300 Subject: [PATCH 10/44] chore: setup memory as postinstall --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b2d7903a..9498b1a45 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:e2e:headed": "turbo test:e2e:headed", "test:e2e:ui": "turbo test:e2e:ui", "changeset": "changeset", - "setup:memory": "node scripts/setup-memory.mjs" + "postinstall": "node scripts/setup-memory.mjs" }, "license": "MIT", "packageManager": "pnpm@10.10.0", From 257f9180d4656d31ed51e16c6e36b9d75890e7d7 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 11:27:57 -0300 Subject: [PATCH 11/44] chore(dashboard): panel min height 400px --- apps/dashboard/features/panel/components/PanelTable.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/dashboard/features/panel/components/PanelTable.tsx b/apps/dashboard/features/panel/components/PanelTable.tsx index bbd910955..f72103d41 100644 --- a/apps/dashboard/features/panel/components/PanelTable.tsx +++ b/apps/dashboard/features/panel/components/PanelTable.tsx @@ -223,6 +223,7 @@ export const PanelTable = () => { data={allDaos} withSorting={true} fillHeight={true} + wrapperClassName="min-h-[400px]" stickyFirstColumn={true} pinRowsToBottom={(row) => row.isPartiallyIndexed} getRowClassName={(row, index, rows) => { From 303131572b12e8a9196a91ac9bd865b0977c2470 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 12:16:26 -0300 Subject: [PATCH 12/44] feat(tornado-governance-page): first draft --- .changeset/torn-proposal-display.md | 5 + .../(main)/proposals/[proposalId]/page.tsx | 3 +- .../proposal-overview/ActionTabContent.tsx | 23 +- .../DescriptionTabContent.tsx | 3 +- .../ProposalActionsInfoCard.tsx | 84 +++++ .../utils/normalizeProposalDescription.ts | 35 +++ .../delegate/drawer/votes/ProposalsTable.tsx | 6 +- scripts/dev.sh | 296 ------------------ scripts/setup-memory.mjs | 48 --- scripts/wait-for-gateful.mjs | 202 ------------ scripts/wait-for-gateful.test.mjs | 102 ------ 11 files changed, 149 insertions(+), 658 deletions(-) create mode 100644 .changeset/torn-proposal-display.md create mode 100644 apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx create mode 100644 apps/dashboard/features/governance/utils/normalizeProposalDescription.ts delete mode 100755 scripts/dev.sh delete mode 100644 scripts/setup-memory.mjs delete mode 100644 scripts/wait-for-gateful.mjs delete mode 100644 scripts/wait-for-gateful.test.mjs diff --git a/.changeset/torn-proposal-display.md b/.changeset/torn-proposal-display.md new file mode 100644 index 000000000..1dab9e17c --- /dev/null +++ b/.changeset/torn-proposal-display.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +Render Tornado Cash proposal descriptions as Markdown (unwrapping the stringified-JSON body) and show a proposal Info card on the Actions tab for proposals without executable actions. diff --git a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx index 039dc0457..765f1c753 100644 --- a/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/proposals/[proposalId]/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { permanentRedirect } from "next/navigation"; import { ProposalSection } from "@/features/governance/components/proposal-overview/ProposalSection"; +import { normalizeProposalDescription } from "@/features/governance/utils/normalizeProposalDescription"; import { buildProposalSeoText } from "@/shared/seo/proposalMetadata"; import type { DaoIdEnum } from "@/shared/types/daos"; import { @@ -45,7 +46,7 @@ export async function generateMetadata(props: Props): Promise { ? isOffchainProposal(proposal) ? proposal.body : proposal.variant === "full" - ? proposal.description + ? normalizeProposalDescription(proposal.description) : undefined : undefined; diff --git a/apps/dashboard/features/governance/components/proposal-overview/ActionTabContent.tsx b/apps/dashboard/features/governance/components/proposal-overview/ActionTabContent.tsx index 2b6798fb3..26700c202 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ActionTabContent.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ActionTabContent.tsx @@ -1,12 +1,12 @@ "use client"; -import { Inbox } from "lucide-react"; import { useParams } from "next/navigation"; import { useState } from "react"; +import { ProposalActionsInfoCard } from "@/features/governance/components/proposal-overview/ProposalActionsInfoCard"; import { useDecodeCalldata } from "@/features/governance/hooks/useDecodeCalldata"; import type { ProposalDetails } from "@/features/governance/types"; -import { BlankSlate, Button } from "@/shared/components"; +import { Button } from "@/shared/components"; import { EnsAvatar } from "@/shared/components/design-system/avatars/ens-avatar/EnsAvatar"; import { DefaultLink } from "@/shared/components/design-system/links/default-link"; import daoConfigByDaoId from "@/shared/dao-config"; @@ -52,13 +52,22 @@ export const ActionsTabContent = ({ const values = proposal.values ?? []; const calldatas = proposal.calldatas ?? []; + // A proposal is executable only when an action has a calldata to run. Some + // DAOs (e.g. Tornado Cash) expose a target but no calldata/value; for those we + // show the proposal's metadata instead of an empty action list. + const hasExecutableActions = targets.some( + (target, index) => + target != null && + values[index] != null && + (calldatas[index] ?? null) != null, + ); + return (
- {targets.length === 0 ? ( - ) : ( targets.map((_, index) => ( diff --git a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx index 4d086502c..9f7860454 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/DescriptionTabContent.tsx @@ -1,6 +1,7 @@ import Markdown from "markdown-to-jsx"; import type { ProposalDetails } from "@/features/governance/types"; +import { normalizeProposalDescription } from "@/features/governance/utils/normalizeProposalDescription"; interface DescriptionTabContentProps { proposal: ProposalDetails; @@ -176,7 +177,7 @@ export const DescriptionTabContent = ({ }, }} > - {cleanMarkdown(proposal.description)} + {cleanMarkdown(normalizeProposalDescription(proposal.description))}
); diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx new file mode 100644 index 000000000..4c6898fb7 --- /dev/null +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx @@ -0,0 +1,84 @@ +import { Info } from "lucide-react"; + +import { ProposalBadge } from "@/features/governance/components/proposal-overview/ProposalBadge"; +import { ProposalInfoText } from "@/features/governance/components/proposal-overview/ProposalInfoText"; +import type { ProposalDetails } from "@/features/governance/types"; +import { DefaultLink } from "@/shared/components/design-system/links/default-link"; + +const formatDateTime = (timestamp: number): string => + new Date(timestamp * 1000).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + +/** + * Shown on the Actions tab for proposals without executable actions (e.g. Tornado + * Cash, whose proposals carry only a target and no calldata). Surfaces the core + * proposal metadata in place of an empty action list. + */ +export const ProposalActionsInfoCard = ({ + proposal, + blockExplorerUrl, +}: { + proposal: ProposalDetails; + blockExplorerUrl: string; +}) => { + const proposalAddress = proposal.targets?.[0] ?? null; + + return ( +
+
+ + Info +
+ + {proposalAddress && ( +
+ Proposal Address + + {proposalAddress} + +
+ )} + +
+
+ ID +

+ {proposal.id} +

+
+
+ Status +
+ +
+
+
+ +
+
+ Start Date +

+ {formatDateTime(proposal.startTimestamp)} +

+
+
+ End Date +

+ {formatDateTime(proposal.endTimestamp)} +

+
+
+
+ ); +}; diff --git a/apps/dashboard/features/governance/utils/normalizeProposalDescription.ts b/apps/dashboard/features/governance/utils/normalizeProposalDescription.ts new file mode 100644 index 000000000..4e648a614 --- /dev/null +++ b/apps/dashboard/features/governance/utils/normalizeProposalDescription.ts @@ -0,0 +1,35 @@ +/** + * Display-only normalizer for proposal descriptions. + * + * Most DAOs return `description` as plain Markdown. Tornado Cash (TORN) instead + * returns a stringified JSON object of shape `{ "title": "…", "description": "…" }`, + * which would otherwise render as raw JSON. This unwraps that body so the Markdown + * renderer receives clean text. + * + * IMPORTANT: use only for display. Never normalize the description that feeds + * on-chain queue/execute — that must stay byte-for-byte identical to the on-chain + * value for proposal-hash matching. + */ +export const normalizeProposalDescription = ( + description?: string | null, +): string => { + if (!description) return ""; + + const trimmed = description.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.description === "string" + ) { + return parsed.description; + } + } catch { + /* not JSON — fall through to raw description */ + } + } + + return description; +}; diff --git a/apps/dashboard/features/holders-and-delegates/delegate/drawer/votes/ProposalsTable.tsx b/apps/dashboard/features/holders-and-delegates/delegate/drawer/votes/ProposalsTable.tsx index 0fc23f64d..1c0c91333 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/drawer/votes/ProposalsTable.tsx +++ b/apps/dashboard/features/holders-and-delegates/delegate/drawer/votes/ProposalsTable.tsx @@ -12,6 +12,7 @@ import { usePathname } from "next/navigation"; import type { ReactNode } from "react"; import { useMemo } from "react"; +import { normalizeProposalDescription } from "@/features/governance/utils/normalizeProposalDescription"; import { DEFAULT_ITEMS_PER_PAGE } from "@/features/holders-and-delegates/utils"; import { getUserVoteData, @@ -102,7 +103,10 @@ export const ProposalsTable = ({ ); return { proposalId: item.proposal?.id || "", - proposalName: item.proposal?.title || item.proposal?.description || "", + proposalName: + item.proposal?.title || + normalizeProposalDescription(item.proposal?.description) || + "", finalResult: finalResult.text, userVote: userVote.text, finalResultIcon: finalResult.icon, diff --git a/scripts/dev.sh b/scripts/dev.sh deleted file mode 100755 index a2556ed29..000000000 --- a/scripts/dev.sh +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -USE_RAILWAY=false -RUN_INDEXER=false -DEBUG_API=false -DAO_NAME="" - -# Parse arguments -for arg in "$@"; do - case "$arg" in - --rw) USE_RAILWAY=true ;; - --indexer) RUN_INDEXER=true ;; - --debug-api) DEBUG_API=true ;; - *) DAO_NAME="$arg" ;; - esac -done - -# Ports -PORT_INDEXER=42070 -PORT_API=42069 -PORT_GATEFUL=4001 -PORT_DASHBOARD=3000 -PORT_ADDRESS_ENRICHMENT=3001 -PORT_RELAYER=3002 -PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT" "$PORT_RELAYER") - -# DAO name → short ID mapping (used to run the API) -dao_id_for() { - case "$1" in - uniswap) echo "uni" ;; - gitcoin) echo "gtc" ;; - scroll) echo "scr" ;; - tornado) echo "torn" ;; - shutter) echo "shu" ;; - compound) echo "comp" ;; - *) echo "$1" ;; - esac -} - -# Derived flags -RUN_API=false -DAO_ID="" -if [ -n "$DAO_NAME" ]; then - RUN_API=true - DAO_ID=$(dao_id_for "$DAO_NAME") -fi - -# Colors per service -C_INDEXER="\033[31m" # red -C_API="\033[34m" # blue -C_GATEFUL="\033[36m" # cyan -C_CODEGEN="\033[33m" # yellow -C_DASHBOARD="\033[32m" # green -C_ADDRESS_ENRICHMENT="\033[96m" # bright cyan -C_RELAYER="\033[93m" # bright yellow -C_SCRIPT="\033[90m" # gray -C_RESET="\033[0m" - -log() { printf "${C_SCRIPT}[dev]${C_RESET} %s\n" "$*"; } - -run_with_prefix() { - local color=$1 label=$2 ready_file=$3 ready_pattern=$4 - shift 4 - log "Running: $*" - "$@" 2>&1 | while IFS= read -r line; do - printf "${color}[%s]${C_RESET} %s\n" "$label" "$line" - if [ -n "$ready_file" ] && [ -n "$ready_pattern" ] && [[ "$line" == *"$ready_pattern"* ]]; then - touch "$ready_file" - ready_pattern="" - fi - done -} - -run_errors_only() { - local color=$1 label=$2 - shift 2 - log "Running: $*" - "$@" 2>&1 | while IFS= read -r line; do - if [[ "$line" =~ [Ee][Rr][Rr][Oo][Rr] ]] || [[ "$line" =~ [Ff][Aa][Ii][Ll] ]]; then - printf "${color}[%s]${C_RESET} %s\n" "$label" "$line" - fi - done -} - -wait_for_ready() { - local ready_file=$1 name=$2 timeout=${3:-120} elapsed=0 - log "Waiting for $name..." - while [ ! -f "$ready_file" ]; do - sleep 1 - elapsed=$((elapsed + 1)) - if [ "$elapsed" -ge "$timeout" ]; then - log "Timed out waiting for $name" - exit 1 - fi - done - log "$name is ready" -} - -wait_for_port() { - local port=$1 name=$2 timeout=${3:-60} elapsed=0 - log "Waiting for $name on port $port..." - while ! (lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1 || ss -tlnH "sport = :$port" 2>/dev/null | grep -q LISTEN); do - sleep 1 - elapsed=$((elapsed + 1)) - if [ "$elapsed" -ge "$timeout" ]; then - log "Timed out waiting for $name on port $port" - exit 1 - fi - done - log "$name is ready on port $port" -} - -wait_for_optional_port() { - local port=$1 name=$2 timeout=${3:-20} elapsed=0 - log "Waiting for optional $name on port $port..." - while ! (lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1 || ss -tlnH "sport = :$port" 2>/dev/null | grep -q LISTEN); do - sleep 1 - elapsed=$((elapsed + 1)) - if [ "$elapsed" -ge "$timeout" ]; then - log "Optional $name did not become ready; continuing without it" - return 1 - fi - done - log "$name is ready on port $port" - return 0 -} - -start_gateful() { - log "Starting Gateful..." - run_with_prefix "$C_GATEFUL" "🚪 gateful" "" "" railway_run gateful pnpm gateful dev & - wait_for_port "$PORT_GATEFUL" "Gateful" 120 -} - -start_relayer() { - export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & - wait_for_port "$PORT_RELAYER" "Relayer" -} - -if [ "${BASH_SOURCE[0]}" != "$0" ]; then - return 0 -fi - -cleanup() { - echo "" - log "Shutting down..." - trap - INT TERM EXIT - kill 0 2>/dev/null || true - wait 2>/dev/null -} -trap cleanup INT TERM EXIT - -# Wrap a command with `railway run` for env injection when --rw flag is set -railway_run() { - local -a overrides=() - while [[ "${1:-}" == *=* ]]; do - overrides+=("$1") - shift - done - - local service=$1 - shift - if [ "$USE_RAILWAY" = true ]; then - log "railway_run: railway run -e dev -s $service $*" - railway run -e dev -s "$service" "$@" - else - log "railway_run (no --rw): $*" - "$@" - fi -} - -# Always try railway for the API; fall back to plain execution (.env) if service not found -railway_run_api() { - local service=$1 - shift - if railway run -e dev -s "$service" true >/dev/null 2>&1; then - log "railway_run_api: railway run -e dev -s $service $*" - railway run -e dev -s "$service" "$@" - else - log "railway_run_api: Railway service '$service' not found, falling back to: $*" - "$@" - fi -} - -railway_service_available() { - local service=$1 - railway run -e dev -s "$service" true >/dev/null 2>&1 -} - -# Kill anything already running on our ports -for port in "${PORTS[@]}"; do - pid=$(lsof -ti ":$port" 2>/dev/null || true) - if [ -n "$pid" ]; then - log "Killing existing process on port $port (pid $pid)" - kill "$pid" 2>/dev/null || true - fi -done -sleep 1 - -# 1. Indexer (only with --indexer flag, requires API) -if [ "$RUN_INDEXER" = true ] && [ "$RUN_API" = true ]; then - log "Starting Indexer for $DAO_NAME..." - export RAILWAY_DEPLOYMENT_ID="${DAO_NAME}-dev" - run_with_prefix "$C_INDEXER" "⛓ indexer" "" "" pnpm indexer start -- --port "$PORT_INDEXER" & -elif [ "$RUN_INDEXER" = true ]; then - log "Skipping Indexer (requires DAO_ID to run)" -else - log "Skipping Indexer (use --indexer to enable)" -fi - -# 2. API (only when DAO_ID is provided) -if [ "$DEBUG_API" = true ] && [ "$RUN_API" = true ]; then - log "Waiting for API on port $PORT_API (start it from your IDE debugger)..." - wait_for_port "$PORT_API" "API (debugger)" - DAO_ID_UPPER=$(echo "$DAO_ID" | tr '[:lower:]' '[:upper:]') - export "DAO_API_${DAO_ID_UPPER}=http://localhost:${PORT_API}" -elif [ "$RUN_API" = true ]; then - log "Starting API for $DAO_NAME..." - run_with_prefix "$C_API" "🐙 api" "" "" railway_run_api "${DAO_NAME}-api" pnpm api dev -- "$DAO_NAME" & - - wait_for_port "$PORT_API" "API" - DAO_ID_UPPER=$(echo "$DAO_ID" | tr '[:lower:]' '[:upper:]') - export "DAO_API_${DAO_ID_UPPER}=http://localhost:${PORT_API}" -else - log "Skipping API (no DAO_NAME provided, using DAO_API_* from .env)" -fi - -# 3. Address Enrichment (optional; do not block the rest of the stack) -ADDRESS_ENRICHMENT_AVAILABLE=false -if railway_service_available "address-enrichment"; then - log "Starting optional Address Enrichment..." - run_with_prefix "$C_ADDRESS_ENRICHMENT" "💰 enrichment" "" "" railway run -e dev -s address-enrichment pnpm address dev & - if wait_for_optional_port "$PORT_ADDRESS_ENRICHMENT" "Address Enrichment"; then - ADDRESS_ENRICHMENT_AVAILABLE=true - export ADDRESS_ENRICHMENT_API_URL="http://localhost:${PORT_ADDRESS_ENRICHMENT}" - fi -else - log "Skipping optional Address Enrichment (Railway CLI/service unavailable)" -fi - -# Watchdog: when API recovers after being down, touch the sentinel file so tsx reloads the gateful -if [ "$RUN_API" = true ]; then - ( - api_was_up=true - while true; do - sleep 3 - if lsof -i ":$PORT_API" -sTCP:LISTEN >/dev/null 2>&1; then - if [ "$api_was_up" = false ]; then - log "API recovered — reloading Gateful..." - touch "$(dirname "$0")/../apps/gateful/src/_dev-reload.ts" - api_was_up=true - fi - else - api_was_up=false - fi - done - ) & -fi - -# 5. Relayer (only available for ENS) -start_relayer - -# 6. Gateful -start_gateful - -# 7. Clients — codegen + build watch -export ANTICAPTURE_API_URL="http://localhost:${PORT_GATEFUL}" -log "Starting REST Client (silent, errors only)..." -run_errors_only "$C_CODEGEN" "🤝 client" pnpm client dev & - -# 8. Dashboard -log "Starting Dashboard..." -run_with_prefix "$C_DASHBOARD" "📺 dashboard" "" "" pnpm dashboard dev & - -echo "" -log "All services running:" -if [ "$RUN_INDEXER" = true ] && [ "$RUN_API" = true ]; then - printf " ${C_INDEXER}⛓ Indexer${C_RESET} http://localhost:${PORT_INDEXER} ($DAO_NAME)\n" -fi -if [ "$RUN_API" = true ]; then - printf " ${C_API}🐙 API${C_RESET} http://localhost:${PORT_API} ($DAO_NAME)\n" -fi -if [ "$ADDRESS_ENRICHMENT_AVAILABLE" = true ]; then - printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} http://localhost:${PORT_ADDRESS_ENRICHMENT}\n" -else - printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" -fi -printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" -printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" -printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" -printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" -echo "" -log "Press Ctrl+C to stop all services." - -wait diff --git a/scripts/setup-memory.mjs b/scripts/setup-memory.mjs deleted file mode 100644 index 15e29d203..000000000 --- a/scripts/setup-memory.mjs +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node -// Points Claude Code's auto-memory at the repo-tracked shared folder. -// -// `autoMemoryDirectory` must be an ABSOLUTE path (Claude Code rejects repo-relative -// paths), so it can't live in the committed settings.json — each clone differs. This -// resolves the absolute path for THIS machine and writes it into the gitignored -// .claude/settings.local.json. Run once per dev: `pnpm setup:memory`. - -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; - -const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const memoryDir = join(repoRoot, ".agents", "shared-memory"); -const settingsPath = join(repoRoot, ".claude", "settings.local.json"); - -let settings = {}; -if (existsSync(settingsPath)) { - const raw = readFileSync(settingsPath, "utf8").trim(); - if (raw) { - try { - settings = JSON.parse(raw); - } catch { - console.error( - `\n ✗ Could not parse ${settingsPath}.\n` + - ` Add this key by hand instead:\n "autoMemoryDirectory": "${memoryDir}"\n`, - ); - process.exit(1); - } - } -} else { - mkdirSync(dirname(settingsPath), { recursive: true }); -} - -if (settings.autoMemoryDirectory === memoryDir) { - console.log(`\n ✓ autoMemoryDirectory already set to ${memoryDir}\n`); - process.exit(0); -} - -settings.autoMemoryDirectory = memoryDir; -writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); - -console.log( - `\n ✓ Set autoMemoryDirectory -> ${memoryDir}\n` + - ` in ${settingsPath}\n\n` + - ` Restart your Claude Code session for it to take effect.\n` + - ` Inspect with the /memory command.\n`, -); diff --git a/scripts/wait-for-gateful.mjs b/scripts/wait-for-gateful.mjs deleted file mode 100644 index c659b2d46..000000000 --- a/scripts/wait-for-gateful.mjs +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env node - -import { pathToFileURL } from "node:url"; - -const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; -const DEFAULT_INTERVAL_MS = 10 * 1000; -const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 1000; - -const readNonEmptyValue = (value) => { - const trimmed = value?.trim(); - - return trimmed ? trimmed : undefined; -}; - -const trimTrailingSlashes = (url) => url.replace(/\/+$/, ""); - -export const resolveGatefulBaseUrl = (env = process.env) => { - const gatefulUrl = readNonEmptyValue(env.ANTICAPTURE_API_URL); - - if (!gatefulUrl) { - throw new Error("Unable to resolve Gateful URL. Set ANTICAPTURE_API_URL."); - } - - const base = trimTrailingSlashes(gatefulUrl); - - return /^https?:\/\//i.test(base) ? base : `https://${base}`; -}; - -export const resolveExpectedGatefulSha = (env = process.env) => - readNonEmptyValue(env.EXPECTED_GATEFUL_SHA) ?? - readNonEmptyValue(env.VERCEL_GIT_COMMIT_SHA); - -const parsePositiveInteger = (value, fallback) => { - const parsed = Number.parseInt(value ?? "", 10); - - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; -}; - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const readHealthBody = async (response) => { - try { - return await response.json(); - } catch { - return undefined; - } -}; - -export const fetchGatefulHealth = async ( - baseUrl, - fetchImpl = globalThis.fetch, - requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, -) => { - if (!fetchImpl) { - throw new Error("global fetch is unavailable; use Node.js 18 or newer."); - } - - const response = await fetchImpl(`${baseUrl}/health`, { - signal: AbortSignal.timeout(requestTimeoutMs), - }); - - return { - status: response.status, - body: await readHealthBody(response), - }; -}; - -export const isGatefulReady = (health, expectedSha) => { - if (health.status !== 200) { - return false; - } - - if (!expectedSha) { - return true; - } - - return health.body?.commit === expectedSha; -}; - -export const waitForGateful = async ({ - baseUrl, - expectedSha, - timeoutMs = DEFAULT_TIMEOUT_MS, - intervalMs = DEFAULT_INTERVAL_MS, - requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - fetchImpl = globalThis.fetch, - sleepImpl = sleep, - now = Date.now, - logger = console, -} = {}) => { - const startedAt = now(); - const deadline = startedAt + timeoutMs; - let attempt = 0; - let lastHealth; - let lastError; - - while (now() <= deadline) { - attempt += 1; - - try { - lastHealth = await fetchGatefulHealth( - baseUrl, - fetchImpl, - requestTimeoutMs, - ); - lastError = undefined; - - if (isGatefulReady(lastHealth, expectedSha)) { - logger.log( - expectedSha - ? `Gateful ready after attempt ${attempt}: commit ${expectedSha}` - : `Gateful ready after attempt ${attempt}`, - ); - - return { ready: true, attempt, lastHealth }; - } - - logger.log( - `attempt ${attempt}: HTTP ${lastHealth.status}, commit ${ - lastHealth.body?.commit ?? "" - }; waiting for ${expectedSha}`, - ); - } catch (error) { - lastError = error; - logger.log( - `attempt ${attempt}: ${ - error instanceof Error ? error.message : String(error) - }; retrying`, - ); - } - - await sleepImpl(intervalMs); - } - - return { ready: false, attempt, lastHealth, lastError }; -}; - -const main = async () => { - const baseUrl = resolveGatefulBaseUrl(); - const expectedSha = resolveExpectedGatefulSha(); - const timeoutMs = parsePositiveInteger( - process.env.GATEFUL_WAIT_TIMEOUT_MS, - DEFAULT_TIMEOUT_MS, - ); - const intervalMs = parsePositiveInteger( - process.env.GATEFUL_WAIT_INTERVAL_MS, - DEFAULT_INTERVAL_MS, - ); - const requestTimeoutMs = parsePositiveInteger( - process.env.GATEFUL_WAIT_REQUEST_TIMEOUT_MS, - DEFAULT_REQUEST_TIMEOUT_MS, - ); - - const result = await waitForGateful({ - baseUrl, - expectedSha, - timeoutMs, - intervalMs, - requestTimeoutMs, - }); - - if (result.ready) { - return; - } - - if (process.env.GATEFUL_WAIT_SOFT === "1") { - const reachability = await waitForGateful({ - baseUrl, - timeoutMs: requestTimeoutMs, - intervalMs: Math.min(intervalMs, 1000), - requestTimeoutMs, - }); - - if (reachability.ready) { - console.warn( - `::warning::Gateful did not serve expected commit ${expectedSha ?? ""} before timeout, but /health is reachable.`, - ); - return; - } - } - - const details = result.lastError - ? result.lastError instanceof Error - ? result.lastError.message - : String(result.lastError) - : `last health: HTTP ${result.lastHealth?.status ?? ""}, commit ${ - result.lastHealth?.body?.commit ?? "" - }`; - - throw new Error( - `Gateful was not ready at ${baseUrl}/health after ${timeoutMs}ms; expected commit ${ - expectedSha ?? "" - }; ${details}`, - ); -}; - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(`::error::${error instanceof Error ? error.message : error}`); - process.exit(1); - }); -} diff --git a/scripts/wait-for-gateful.test.mjs b/scripts/wait-for-gateful.test.mjs deleted file mode 100644 index 4a1586994..000000000 --- a/scripts/wait-for-gateful.test.mjs +++ /dev/null @@ -1,102 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - isGatefulReady, - resolveExpectedGatefulSha, - resolveGatefulBaseUrl, - waitForGateful, -} from "./wait-for-gateful.mjs"; - -const jsonResponse = (status, body) => ({ - status, - json: async () => body, -}); - -test("resolveGatefulBaseUrl reads ANTICAPTURE_API_URL only", () => { - assert.equal( - resolveGatefulBaseUrl({ - ANTICAPTURE_API_URL: "https://preview.example.com/", - }), - "https://preview.example.com", - ); - assert.equal( - resolveGatefulBaseUrl({ ANTICAPTURE_API_URL: "gateful.internal" }), - "https://gateful.internal", - ); - assert.throws(() => resolveGatefulBaseUrl({})); -}); - -test("resolveExpectedGatefulSha prefers explicit expected SHA", () => { - assert.equal( - resolveExpectedGatefulSha({ - EXPECTED_GATEFUL_SHA: "head-sha", - VERCEL_GIT_COMMIT_SHA: "vercel-sha", - }), - "head-sha", - ); - assert.equal( - resolveExpectedGatefulSha({ VERCEL_GIT_COMMIT_SHA: "vercel-sha" }), - "vercel-sha", - ); -}); - -test("isGatefulReady requires the matching commit when expected", () => { - assert.equal(isGatefulReady({ status: 200, body: {} }, undefined), true); - assert.equal( - isGatefulReady({ status: 200, body: { commit: "abc" } }, "abc"), - true, - ); - assert.equal( - isGatefulReady({ status: 200, body: { commit: "old" } }, "abc"), - false, - ); - assert.equal( - isGatefulReady({ status: 503, body: { commit: "abc" } }, "abc"), - false, - ); -}); - -test("waitForGateful polls until the expected commit is live", async () => { - const responses = [ - jsonResponse(200, { status: "ok", commit: "old" }), - jsonResponse(200, { status: "ok", commit: "new" }), - ]; - let nowMs = 0; - - const result = await waitForGateful({ - baseUrl: "https://gateful.example.com", - expectedSha: "new", - timeoutMs: 100, - intervalMs: 10, - fetchImpl: async () => responses.shift(), - sleepImpl: async (ms) => { - nowMs += ms; - }, - now: () => nowMs, - logger: { log: () => undefined }, - }); - - assert.equal(result.ready, true); - assert.equal(result.attempt, 2); -}); - -test("waitForGateful times out on stale commits", async () => { - let nowMs = 0; - - const result = await waitForGateful({ - baseUrl: "https://gateful.example.com", - expectedSha: "new", - timeoutMs: 10, - intervalMs: 10, - fetchImpl: async () => jsonResponse(200, { status: "ok", commit: "old" }), - sleepImpl: async (ms) => { - nowMs += ms; - }, - now: () => nowMs, - logger: { log: () => undefined }, - }); - - assert.equal(result.ready, false); - assert.equal(result.attempt, 2); -}); From 00f4c26d508b53e7537ab91064ff0a1628daab8b Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 13:32:39 -0300 Subject: [PATCH 13/44] feat(tornado-governance-page): action tab design adjustments --- .../ProposalActionsInfoCard.tsx | 86 ++--- scripts/dev.sh | 322 ++++++++++++++++++ scripts/setup-memory.mjs | 48 +++ scripts/wait-for-gateful.mjs | 202 +++++++++++ scripts/wait-for-gateful.test.mjs | 102 ++++++ 5 files changed, 704 insertions(+), 56 deletions(-) create mode 100755 scripts/dev.sh create mode 100644 scripts/setup-memory.mjs create mode 100644 scripts/wait-for-gateful.mjs create mode 100644 scripts/wait-for-gateful.test.mjs diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx index 4c6898fb7..357d02632 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx @@ -1,20 +1,8 @@ -import { Info } from "lucide-react"; - -import { ProposalBadge } from "@/features/governance/components/proposal-overview/ProposalBadge"; import { ProposalInfoText } from "@/features/governance/components/proposal-overview/ProposalInfoText"; import type { ProposalDetails } from "@/features/governance/types"; +import { CopyAndPasteButton } from "@/shared/components/buttons/CopyAndPasteButton"; import { DefaultLink } from "@/shared/components/design-system/links/default-link"; -const formatDateTime = (timestamp: number): string => - new Date(timestamp * 1000).toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - /** * Shown on the Actions tab for proposals without executable actions (e.g. Tornado * Cash, whose proposals carry only a target and no calldata). Surfaces the core @@ -30,55 +18,41 @@ export const ProposalActionsInfoCard = ({ const proposalAddress = proposal.targets?.[0] ?? null; return ( -
-
- - Info +
+
+
+

+ // Action +

+
+ + Contract +
{proposalAddress && ( -
+
Proposal Address - - {proposalAddress} - -
- )} - -
-
- ID -

- {proposal.id} -

-
-
- Status -
- +
+

+ {proposalAddress} +

+
-
- -
-
- Start Date -

- {formatDateTime(proposal.startTimestamp)} -

-
-
- End Date -

- {formatDateTime(proposal.endTimestamp)} -

-
-
+ )}
); }; diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 000000000..dd6a5038a --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +set -euo pipefail + +USE_RAILWAY=false +RUN_INDEXER=false +DEBUG_API=false +DAO_NAME="" + +# Parse arguments +for arg in "$@"; do + case "$arg" in + --rw) USE_RAILWAY=true ;; + --indexer) RUN_INDEXER=true ;; + --debug-api) DEBUG_API=true ;; + *) DAO_NAME="$arg" ;; + esac +done + +# Ports +PORT_INDEXER=42070 +PORT_API=42069 +PORT_GATEFUL=4001 +PORT_DASHBOARD=3000 +PORT_ADDRESS_ENRICHMENT=3001 +PORT_RELAYER=3002 +PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT" "$PORT_RELAYER") + +# DAO name → short ID mapping (used to run the API) +dao_id_for() { + case "$1" in + uniswap) echo "uni" ;; + gitcoin) echo "gtc" ;; + scroll) echo "scr" ;; + tornado) echo "torn" ;; + shutter) echo "shu" ;; + compound) echo "comp" ;; + *) echo "$1" ;; + esac +} + +# Derived flags +RUN_API=false +DAO_ID="" +if [ -n "$DAO_NAME" ]; then + RUN_API=true + DAO_ID=$(dao_id_for "$DAO_NAME") +fi + +# Colors per service +C_INDEXER="\033[31m" # red +C_API="\033[34m" # blue +C_GATEFUL="\033[36m" # cyan +C_CODEGEN="\033[33m" # yellow +C_DASHBOARD="\033[32m" # green +C_ADDRESS_ENRICHMENT="\033[96m" # bright cyan +C_RELAYER="\033[93m" # bright yellow +C_SCRIPT="\033[90m" # gray +C_RESET="\033[0m" + +log() { printf "${C_SCRIPT}[dev]${C_RESET} %s\n" "$*"; } + +run_with_prefix() { + local color=$1 label=$2 ready_file=$3 ready_pattern=$4 + shift 4 + log "Running: $*" + "$@" 2>&1 | while IFS= read -r line; do + printf "${color}[%s]${C_RESET} %s\n" "$label" "$line" + if [ -n "$ready_file" ] && [ -n "$ready_pattern" ] && [[ "$line" == *"$ready_pattern"* ]]; then + touch "$ready_file" + ready_pattern="" + fi + done +} + +run_errors_only() { + local color=$1 label=$2 + shift 2 + log "Running: $*" + "$@" 2>&1 | while IFS= read -r line; do + if [[ "$line" =~ [Ee][Rr][Rr][Oo][Rr] ]] || [[ "$line" =~ [Ff][Aa][Ii][Ll] ]]; then + printf "${color}[%s]${C_RESET} %s\n" "$label" "$line" + fi + done +} + +wait_for_ready() { + local ready_file=$1 name=$2 timeout=${3:-120} elapsed=0 + log "Waiting for $name..." + while [ ! -f "$ready_file" ]; do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -ge "$timeout" ]; then + log "Timed out waiting for $name" + exit 1 + fi + done + log "$name is ready" +} + +wait_for_port() { + local port=$1 name=$2 timeout=${3:-60} elapsed=0 + log "Waiting for $name on port $port..." + while ! (lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1 || ss -tlnH "sport = :$port" 2>/dev/null | grep -q LISTEN); do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -ge "$timeout" ]; then + log "Timed out waiting for $name on port $port" + exit 1 + fi + done + log "$name is ready on port $port" +} + +wait_for_optional_port() { + local port=$1 name=$2 timeout=${3:-20} elapsed=0 + log "Waiting for optional $name on port $port..." + while ! (lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1 || ss -tlnH "sport = :$port" 2>/dev/null | grep -q LISTEN); do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -ge "$timeout" ]; then + log "Optional $name did not become ready; continuing without it" + return 1 + fi + done + log "$name is ready on port $port" + return 0 +} + +start_gateful() { + log "Starting Gateful..." + run_with_prefix "$C_GATEFUL" "🚪 gateful" "" "" railway_run gateful pnpm gateful dev & + wait_for_port "$PORT_GATEFUL" "Gateful" 120 +} + +start_relayer() { + export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" + # The relayer reaches the chain via RPC_URL, which in dev points at an + # erpc.railway.internal host unreachable from a laptop. Set LOCAL_RELAYER_RPC_URL + # to a reachable RPC (e.g. https://erpc-dev.up.railway.app/api/evm/1) to run it + # locally; REDIS_URL is already a public proxy in dev. Without the override the + # relayer crashes on startup, gateful can't fetch its OpenAPI spec, and the + # @anticapture/client codegen omits the relayer hooks the dashboard imports. + if [ -n "${LOCAL_RELAYER_RPC_URL:-}" ]; then + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer bash -c 'RPC_URL="$LOCAL_RELAYER_RPC_URL" exec pnpm relayer dev' & + else + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & + fi + # Optional: a slow/unavailable relayer must not tear down the rest of the stack. + wait_for_optional_port "$PORT_RELAYER" "Relayer" || true +} + +if [ "${BASH_SOURCE[0]}" != "$0" ]; then + return 0 +fi + +cleanup() { + echo "" + log "Shutting down..." + trap - INT TERM EXIT + kill 0 2>/dev/null || true + wait 2>/dev/null +} +trap cleanup INT TERM EXIT + +# Wrap a command with `railway run` for env injection when --rw flag is set +railway_run() { + local -a overrides=() + while [[ "${1:-}" == *=* ]]; do + overrides+=("$1") + shift + done + + local service=$1 + shift + if [ "$USE_RAILWAY" = true ]; then + log "railway_run: railway run -e dev -s $service $*" + railway run -e dev -s "$service" "$@" + else + log "railway_run (no --rw): $*" + "$@" + fi +} + +# Always try railway for the API; fall back to plain execution (.env) if service not found. +# Railway injects dev values that point at *.railway.internal hosts which only resolve +# inside Railway's private network, so from a laptop the API can't reach them: +# - LOCAL_DATABASE_URL overrides DATABASE_URL (DB; e.g. the public proxy URL) +# - LOCAL_RPC_URL overrides RPC_URL (chain; e.g. https://erpc-dev.up.railway.app/api/evm/1) +# Each is applied inside the railway-run child only when set, so default behavior is unchanged. +railway_run_api() { + local service=$1 + shift + if railway run -e dev -s "$service" true >/dev/null 2>&1; then + local overrides="" + [ -n "${LOCAL_DATABASE_URL:-}" ] && overrides="${overrides}DATABASE_URL=\"\$LOCAL_DATABASE_URL\" " + [ -n "${LOCAL_RPC_URL:-}" ] && overrides="${overrides}RPC_URL=\"\$LOCAL_RPC_URL\" " + if [ -n "$overrides" ]; then + log "railway_run_api: railway run -e dev -s $service (local overrides: ${overrides}) $*" + railway run -e dev -s "$service" bash -c "${overrides}exec \"\$@\"" _ "$@" + else + log "railway_run_api: railway run -e dev -s $service $*" + railway run -e dev -s "$service" "$@" + fi + else + log "railway_run_api: Railway service '$service' not found, falling back to: $*" + "$@" + fi +} + +railway_service_available() { + local service=$1 + railway run -e dev -s "$service" true >/dev/null 2>&1 +} + +# Kill anything already running on our ports +for port in "${PORTS[@]}"; do + pid=$(lsof -ti ":$port" 2>/dev/null || true) + if [ -n "$pid" ]; then + log "Killing existing process on port $port (pid $pid)" + kill "$pid" 2>/dev/null || true + fi +done +sleep 1 + +# 1. Indexer (only with --indexer flag, requires API) +if [ "$RUN_INDEXER" = true ] && [ "$RUN_API" = true ]; then + log "Starting Indexer for $DAO_NAME..." + export RAILWAY_DEPLOYMENT_ID="${DAO_NAME}-dev" + run_with_prefix "$C_INDEXER" "⛓ indexer" "" "" pnpm indexer start -- --port "$PORT_INDEXER" & +elif [ "$RUN_INDEXER" = true ]; then + log "Skipping Indexer (requires DAO_ID to run)" +else + log "Skipping Indexer (use --indexer to enable)" +fi + +# 2. API (only when DAO_ID is provided) +if [ "$DEBUG_API" = true ] && [ "$RUN_API" = true ]; then + log "Waiting for API on port $PORT_API (start it from your IDE debugger)..." + wait_for_port "$PORT_API" "API (debugger)" + DAO_ID_UPPER=$(echo "$DAO_ID" | tr '[:lower:]' '[:upper:]') + export "DAO_API_${DAO_ID_UPPER}=http://localhost:${PORT_API}" +elif [ "$RUN_API" = true ]; then + log "Starting API for $DAO_NAME..." + run_with_prefix "$C_API" "🐙 api" "" "" railway_run_api "${DAO_NAME}-api" pnpm api dev -- "$DAO_NAME" & + + wait_for_port "$PORT_API" "API" + DAO_ID_UPPER=$(echo "$DAO_ID" | tr '[:lower:]' '[:upper:]') + export "DAO_API_${DAO_ID_UPPER}=http://localhost:${PORT_API}" +else + log "Skipping API (no DAO_NAME provided, using DAO_API_* from .env)" +fi + +# 3. Address Enrichment (optional; do not block the rest of the stack) +ADDRESS_ENRICHMENT_AVAILABLE=false +if railway_service_available "address-enrichment"; then + log "Starting optional Address Enrichment..." + run_with_prefix "$C_ADDRESS_ENRICHMENT" "💰 enrichment" "" "" railway run -e dev -s address-enrichment pnpm address dev & + if wait_for_optional_port "$PORT_ADDRESS_ENRICHMENT" "Address Enrichment"; then + ADDRESS_ENRICHMENT_AVAILABLE=true + export ADDRESS_ENRICHMENT_API_URL="http://localhost:${PORT_ADDRESS_ENRICHMENT}" + fi +else + log "Skipping optional Address Enrichment (Railway CLI/service unavailable)" +fi + +# Watchdog: when API recovers after being down, touch the sentinel file so tsx reloads the gateful +if [ "$RUN_API" = true ]; then + ( + api_was_up=true + while true; do + sleep 3 + if lsof -i ":$PORT_API" -sTCP:LISTEN >/dev/null 2>&1; then + if [ "$api_was_up" = false ]; then + log "API recovered — reloading Gateful..." + touch "$(dirname "$0")/../apps/gateful/src/_dev-reload.ts" + api_was_up=true + fi + else + api_was_up=false + fi + done + ) & +fi + +# 5. Relayer (ENS service, but its OpenAPI schema is shared across DAOs and is +# required for @anticapture/client codegen — the dashboard imports relayer hooks +# unconditionally — so it runs for every DAO). +start_relayer + +# 6. Gateful +start_gateful + +# 7. Clients — codegen + build watch +export ANTICAPTURE_API_URL="http://localhost:${PORT_GATEFUL}" +log "Starting REST Client (silent, errors only)..." +run_errors_only "$C_CODEGEN" "🤝 client" pnpm client dev & + +# 8. Dashboard +log "Starting Dashboard..." +run_with_prefix "$C_DASHBOARD" "📺 dashboard" "" "" pnpm dashboard dev & + +echo "" +log "All services running:" +if [ "$RUN_INDEXER" = true ] && [ "$RUN_API" = true ]; then + printf " ${C_INDEXER}⛓ Indexer${C_RESET} http://localhost:${PORT_INDEXER} ($DAO_NAME)\n" +fi +if [ "$RUN_API" = true ]; then + printf " ${C_API}🐙 API${C_RESET} http://localhost:${PORT_API} ($DAO_NAME)\n" +fi +if [ "$ADDRESS_ENRICHMENT_AVAILABLE" = true ]; then + printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} http://localhost:${PORT_ADDRESS_ENRICHMENT}\n" +else + printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" +fi +printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" +printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" +printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" +printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" +echo "" +log "Press Ctrl+C to stop all services." + +wait diff --git a/scripts/setup-memory.mjs b/scripts/setup-memory.mjs new file mode 100644 index 000000000..15e29d203 --- /dev/null +++ b/scripts/setup-memory.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Points Claude Code's auto-memory at the repo-tracked shared folder. +// +// `autoMemoryDirectory` must be an ABSOLUTE path (Claude Code rejects repo-relative +// paths), so it can't live in the committed settings.json — each clone differs. This +// resolves the absolute path for THIS machine and writes it into the gitignored +// .claude/settings.local.json. Run once per dev: `pnpm setup:memory`. + +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const memoryDir = join(repoRoot, ".agents", "shared-memory"); +const settingsPath = join(repoRoot, ".claude", "settings.local.json"); + +let settings = {}; +if (existsSync(settingsPath)) { + const raw = readFileSync(settingsPath, "utf8").trim(); + if (raw) { + try { + settings = JSON.parse(raw); + } catch { + console.error( + `\n ✗ Could not parse ${settingsPath}.\n` + + ` Add this key by hand instead:\n "autoMemoryDirectory": "${memoryDir}"\n`, + ); + process.exit(1); + } + } +} else { + mkdirSync(dirname(settingsPath), { recursive: true }); +} + +if (settings.autoMemoryDirectory === memoryDir) { + console.log(`\n ✓ autoMemoryDirectory already set to ${memoryDir}\n`); + process.exit(0); +} + +settings.autoMemoryDirectory = memoryDir; +writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + +console.log( + `\n ✓ Set autoMemoryDirectory -> ${memoryDir}\n` + + ` in ${settingsPath}\n\n` + + ` Restart your Claude Code session for it to take effect.\n` + + ` Inspect with the /memory command.\n`, +); diff --git a/scripts/wait-for-gateful.mjs b/scripts/wait-for-gateful.mjs new file mode 100644 index 000000000..c659b2d46 --- /dev/null +++ b/scripts/wait-for-gateful.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +import { pathToFileURL } from "node:url"; + +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const DEFAULT_INTERVAL_MS = 10 * 1000; +const DEFAULT_REQUEST_TIMEOUT_MS = 10 * 1000; + +const readNonEmptyValue = (value) => { + const trimmed = value?.trim(); + + return trimmed ? trimmed : undefined; +}; + +const trimTrailingSlashes = (url) => url.replace(/\/+$/, ""); + +export const resolveGatefulBaseUrl = (env = process.env) => { + const gatefulUrl = readNonEmptyValue(env.ANTICAPTURE_API_URL); + + if (!gatefulUrl) { + throw new Error("Unable to resolve Gateful URL. Set ANTICAPTURE_API_URL."); + } + + const base = trimTrailingSlashes(gatefulUrl); + + return /^https?:\/\//i.test(base) ? base : `https://${base}`; +}; + +export const resolveExpectedGatefulSha = (env = process.env) => + readNonEmptyValue(env.EXPECTED_GATEFUL_SHA) ?? + readNonEmptyValue(env.VERCEL_GIT_COMMIT_SHA); + +const parsePositiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value ?? "", 10); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const readHealthBody = async (response) => { + try { + return await response.json(); + } catch { + return undefined; + } +}; + +export const fetchGatefulHealth = async ( + baseUrl, + fetchImpl = globalThis.fetch, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, +) => { + if (!fetchImpl) { + throw new Error("global fetch is unavailable; use Node.js 18 or newer."); + } + + const response = await fetchImpl(`${baseUrl}/health`, { + signal: AbortSignal.timeout(requestTimeoutMs), + }); + + return { + status: response.status, + body: await readHealthBody(response), + }; +}; + +export const isGatefulReady = (health, expectedSha) => { + if (health.status !== 200) { + return false; + } + + if (!expectedSha) { + return true; + } + + return health.body?.commit === expectedSha; +}; + +export const waitForGateful = async ({ + baseUrl, + expectedSha, + timeoutMs = DEFAULT_TIMEOUT_MS, + intervalMs = DEFAULT_INTERVAL_MS, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + fetchImpl = globalThis.fetch, + sleepImpl = sleep, + now = Date.now, + logger = console, +} = {}) => { + const startedAt = now(); + const deadline = startedAt + timeoutMs; + let attempt = 0; + let lastHealth; + let lastError; + + while (now() <= deadline) { + attempt += 1; + + try { + lastHealth = await fetchGatefulHealth( + baseUrl, + fetchImpl, + requestTimeoutMs, + ); + lastError = undefined; + + if (isGatefulReady(lastHealth, expectedSha)) { + logger.log( + expectedSha + ? `Gateful ready after attempt ${attempt}: commit ${expectedSha}` + : `Gateful ready after attempt ${attempt}`, + ); + + return { ready: true, attempt, lastHealth }; + } + + logger.log( + `attempt ${attempt}: HTTP ${lastHealth.status}, commit ${ + lastHealth.body?.commit ?? "" + }; waiting for ${expectedSha}`, + ); + } catch (error) { + lastError = error; + logger.log( + `attempt ${attempt}: ${ + error instanceof Error ? error.message : String(error) + }; retrying`, + ); + } + + await sleepImpl(intervalMs); + } + + return { ready: false, attempt, lastHealth, lastError }; +}; + +const main = async () => { + const baseUrl = resolveGatefulBaseUrl(); + const expectedSha = resolveExpectedGatefulSha(); + const timeoutMs = parsePositiveInteger( + process.env.GATEFUL_WAIT_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ); + const intervalMs = parsePositiveInteger( + process.env.GATEFUL_WAIT_INTERVAL_MS, + DEFAULT_INTERVAL_MS, + ); + const requestTimeoutMs = parsePositiveInteger( + process.env.GATEFUL_WAIT_REQUEST_TIMEOUT_MS, + DEFAULT_REQUEST_TIMEOUT_MS, + ); + + const result = await waitForGateful({ + baseUrl, + expectedSha, + timeoutMs, + intervalMs, + requestTimeoutMs, + }); + + if (result.ready) { + return; + } + + if (process.env.GATEFUL_WAIT_SOFT === "1") { + const reachability = await waitForGateful({ + baseUrl, + timeoutMs: requestTimeoutMs, + intervalMs: Math.min(intervalMs, 1000), + requestTimeoutMs, + }); + + if (reachability.ready) { + console.warn( + `::warning::Gateful did not serve expected commit ${expectedSha ?? ""} before timeout, but /health is reachable.`, + ); + return; + } + } + + const details = result.lastError + ? result.lastError instanceof Error + ? result.lastError.message + : String(result.lastError) + : `last health: HTTP ${result.lastHealth?.status ?? ""}, commit ${ + result.lastHealth?.body?.commit ?? "" + }`; + + throw new Error( + `Gateful was not ready at ${baseUrl}/health after ${timeoutMs}ms; expected commit ${ + expectedSha ?? "" + }; ${details}`, + ); +}; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(`::error::${error instanceof Error ? error.message : error}`); + process.exit(1); + }); +} diff --git a/scripts/wait-for-gateful.test.mjs b/scripts/wait-for-gateful.test.mjs new file mode 100644 index 000000000..4a1586994 --- /dev/null +++ b/scripts/wait-for-gateful.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isGatefulReady, + resolveExpectedGatefulSha, + resolveGatefulBaseUrl, + waitForGateful, +} from "./wait-for-gateful.mjs"; + +const jsonResponse = (status, body) => ({ + status, + json: async () => body, +}); + +test("resolveGatefulBaseUrl reads ANTICAPTURE_API_URL only", () => { + assert.equal( + resolveGatefulBaseUrl({ + ANTICAPTURE_API_URL: "https://preview.example.com/", + }), + "https://preview.example.com", + ); + assert.equal( + resolveGatefulBaseUrl({ ANTICAPTURE_API_URL: "gateful.internal" }), + "https://gateful.internal", + ); + assert.throws(() => resolveGatefulBaseUrl({})); +}); + +test("resolveExpectedGatefulSha prefers explicit expected SHA", () => { + assert.equal( + resolveExpectedGatefulSha({ + EXPECTED_GATEFUL_SHA: "head-sha", + VERCEL_GIT_COMMIT_SHA: "vercel-sha", + }), + "head-sha", + ); + assert.equal( + resolveExpectedGatefulSha({ VERCEL_GIT_COMMIT_SHA: "vercel-sha" }), + "vercel-sha", + ); +}); + +test("isGatefulReady requires the matching commit when expected", () => { + assert.equal(isGatefulReady({ status: 200, body: {} }, undefined), true); + assert.equal( + isGatefulReady({ status: 200, body: { commit: "abc" } }, "abc"), + true, + ); + assert.equal( + isGatefulReady({ status: 200, body: { commit: "old" } }, "abc"), + false, + ); + assert.equal( + isGatefulReady({ status: 503, body: { commit: "abc" } }, "abc"), + false, + ); +}); + +test("waitForGateful polls until the expected commit is live", async () => { + const responses = [ + jsonResponse(200, { status: "ok", commit: "old" }), + jsonResponse(200, { status: "ok", commit: "new" }), + ]; + let nowMs = 0; + + const result = await waitForGateful({ + baseUrl: "https://gateful.example.com", + expectedSha: "new", + timeoutMs: 100, + intervalMs: 10, + fetchImpl: async () => responses.shift(), + sleepImpl: async (ms) => { + nowMs += ms; + }, + now: () => nowMs, + logger: { log: () => undefined }, + }); + + assert.equal(result.ready, true); + assert.equal(result.attempt, 2); +}); + +test("waitForGateful times out on stale commits", async () => { + let nowMs = 0; + + const result = await waitForGateful({ + baseUrl: "https://gateful.example.com", + expectedSha: "new", + timeoutMs: 10, + intervalMs: 10, + fetchImpl: async () => jsonResponse(200, { status: "ok", commit: "old" }), + sleepImpl: async (ms) => { + nowMs += ms; + }, + now: () => nowMs, + logger: { log: () => undefined }, + }); + + assert.equal(result.ready, false); + assert.equal(result.attempt, 2); +}); From 01a29aa71bf4d42f7824731bc0c679744caba642 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 13:48:51 -0300 Subject: [PATCH 14/44] chore(tornado-governance-page): add tests to utils + id concatenation --- .../ProposalActionsInfoCard.tsx | 2 +- .../proposal-overview/TitleSection.tsx | 6 +- .../normalizeProposalDescription.test.ts | 72 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/features/governance/utils/normalizeProposalDescription.test.ts diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx index 357d02632..e7d177be2 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx @@ -38,7 +38,7 @@ export const ProposalActionsInfoCard = ({
Proposal Address
-

+

{proposalAddress}

@@ -87,7 +88,10 @@ export const TitleSection = ({
-

{proposal?.title}

+

+ {idSlug} + {proposal?.title} +

diff --git a/apps/dashboard/features/governance/utils/normalizeProposalDescription.test.ts b/apps/dashboard/features/governance/utils/normalizeProposalDescription.test.ts new file mode 100644 index 000000000..37ea03083 --- /dev/null +++ b/apps/dashboard/features/governance/utils/normalizeProposalDescription.test.ts @@ -0,0 +1,72 @@ +import { normalizeProposalDescription } from "./normalizeProposalDescription"; + +describe("normalizeProposalDescription", () => { + it("unwraps the inner description from a TORN stringified-JSON body", () => { + const torn = JSON.stringify({ + title: "Tornado Cash Governance Proposal", + description: "Summary\nThis proposal aims to transition the Registry.", + }); + + expect(normalizeProposalDescription(torn)).toBe( + "Summary\nThis proposal aims to transition the Registry.", + ); + }); + + it("preserves escaped newlines decoded by JSON.parse", () => { + const torn = '{"title":"t","description":"line1\\nline2"}'; + + expect(normalizeProposalDescription(torn)).toBe("line1\nline2"); + }); + + it("returns plain Markdown descriptions unchanged", () => { + const markdown = "# Deprecation of Linea\n## Simple Summary\n\nGauntlet..."; + + expect(normalizeProposalDescription(markdown)).toBe(markdown); + }); + + it("returns the raw string when JSON is malformed", () => { + const broken = '{"title":"t","description":"oops"'; // missing closing brace + + expect(normalizeProposalDescription(broken)).toBe(broken); + }); + + it("returns the raw string when the JSON object has no string description", () => { + const noDescription = JSON.stringify({ title: "t" }); + + expect(normalizeProposalDescription(noDescription)).toBe(noDescription); + }); + + it("returns the raw string when description is not a string", () => { + const numericDescription = '{"title":"t","description":42}'; + + expect(normalizeProposalDescription(numericDescription)).toBe( + numericDescription, + ); + }); + + it("does not treat a JSON array as a wrapped description", () => { + const arrayJson = '["not","an","object"]'; + + expect(normalizeProposalDescription(arrayJson)).toBe(arrayJson); + }); + + it("only attempts to parse strings that start with a brace", () => { + const looksLikeJsonMidway = 'see {"description":"x"} below'; + + expect(normalizeProposalDescription(looksLikeJsonMidway)).toBe( + looksLikeJsonMidway, + ); + }); + + it("handles leading/trailing whitespace around a JSON body", () => { + const padded = ' {"title":"t","description":"trimmed body"} '; + + expect(normalizeProposalDescription(padded)).toBe("trimmed body"); + }); + + it("returns an empty string for empty, null, or undefined input", () => { + expect(normalizeProposalDescription("")).toBe(""); + expect(normalizeProposalDescription(null)).toBe(""); + expect(normalizeProposalDescription(undefined)).toBe(""); + }); +}); From 4ff84b422d7c28f40ceecffe2d73149d0577f6f5 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 14:12:28 -0300 Subject: [PATCH 15/44] fix: remove id concatenation --- .../components/proposal-overview/TitleSection.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/dashboard/features/governance/components/proposal-overview/TitleSection.tsx b/apps/dashboard/features/governance/components/proposal-overview/TitleSection.tsx index 37acf6460..59a6da51e 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/TitleSection.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/TitleSection.tsx @@ -58,7 +58,6 @@ export const TitleSection = ({ const twitterHandle = DAO_TWITTER_HANDLES[proposal.daoId.toUpperCase()] ?? ""; const proposalLink = `${process.env.NEXT_PUBLIC_SITE_URL}/${proposal.daoId.toLowerCase()}/proposals/${proposal.id}`; const twitterText = `🗳️ [${daoConfig?.name ?? proposal.daoId.toUpperCase()} DAO] PROPOSAL DETECTED — STATUS: [${proposal.status.toUpperCase()}] // This transmission needs a response. ${twitterHandle} ${proposalLink}`; - const idSlug = proposal?.id ? `${proposal?.id} - ` : ""; return (
@@ -88,10 +87,7 @@ export const TitleSection = ({
-

- {idSlug} - {proposal?.title} -

+

{proposal?.title}

From 4d96c5172ab7b9d659df16c4a65b68bd50119185 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 14:34:52 -0300 Subject: [PATCH 16/44] fix: revert scripts alteration --- scripts/dev.sh | 60 +++++++++++++++++++++----------------------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index dd6a5038a..13b139357 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -31,7 +31,6 @@ dao_id_for() { uniswap) echo "uni" ;; gitcoin) echo "gtc" ;; scroll) echo "scr" ;; - tornado) echo "torn" ;; shutter) echo "shu" ;; compound) echo "comp" ;; *) echo "$1" ;; @@ -133,20 +132,19 @@ start_gateful() { } start_relayer() { - export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" - # The relayer reaches the chain via RPC_URL, which in dev points at an - # erpc.railway.internal host unreachable from a laptop. Set LOCAL_RELAYER_RPC_URL - # to a reachable RPC (e.g. https://erpc-dev.up.railway.app/api/evm/1) to run it - # locally; REDIS_URL is already a public proxy in dev. Without the override the - # relayer crashes on startup, gateful can't fetch its OpenAPI spec, and the - # @anticapture/client codegen omits the relayer hooks the dashboard imports. - if [ -n "${LOCAL_RELAYER_RPC_URL:-}" ]; then - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer bash -c 'RPC_URL="$LOCAL_RELAYER_RPC_URL" exec pnpm relayer dev' & - else - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & + if [ -z "$DAO_NAME" ]; then + log "Skipping optional Relayer (no DAO selected)" + return 1 + fi + local service="$(echo "$DAO_NAME" | tr '[:upper:]' '[:lower:]')-relayer" + log "Starting optional Relayer ($service)..." + # Always run through `railway run`; if the service doesn't exist it simply + # fails to come up and wait_for_optional_port lets the stack continue. + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s "$service" pnpm relayer dev & + if wait_for_optional_port "$PORT_RELAYER" "Relayer"; then + return 0 fi - # Optional: a slow/unavailable relayer must not tear down the rest of the stack. - wait_for_optional_port "$PORT_RELAYER" "Relayer" || true + return 1 } if [ "${BASH_SOURCE[0]}" != "$0" ]; then @@ -181,26 +179,13 @@ railway_run() { fi } -# Always try railway for the API; fall back to plain execution (.env) if service not found. -# Railway injects dev values that point at *.railway.internal hosts which only resolve -# inside Railway's private network, so from a laptop the API can't reach them: -# - LOCAL_DATABASE_URL overrides DATABASE_URL (DB; e.g. the public proxy URL) -# - LOCAL_RPC_URL overrides RPC_URL (chain; e.g. https://erpc-dev.up.railway.app/api/evm/1) -# Each is applied inside the railway-run child only when set, so default behavior is unchanged. +# Always try railway for the API; fall back to plain execution (.env) if service not found railway_run_api() { local service=$1 shift if railway run -e dev -s "$service" true >/dev/null 2>&1; then - local overrides="" - [ -n "${LOCAL_DATABASE_URL:-}" ] && overrides="${overrides}DATABASE_URL=\"\$LOCAL_DATABASE_URL\" " - [ -n "${LOCAL_RPC_URL:-}" ] && overrides="${overrides}RPC_URL=\"\$LOCAL_RPC_URL\" " - if [ -n "$overrides" ]; then - log "railway_run_api: railway run -e dev -s $service (local overrides: ${overrides}) $*" - railway run -e dev -s "$service" bash -c "${overrides}exec \"\$@\"" _ "$@" - else - log "railway_run_api: railway run -e dev -s $service $*" - railway run -e dev -s "$service" "$@" - fi + log "railway_run_api: railway run -e dev -s $service $*" + railway run -e dev -s "$service" "$@" else log "railway_run_api: Railway service '$service' not found, falling back to: $*" "$@" @@ -282,10 +267,11 @@ if [ "$RUN_API" = true ]; then ) & fi -# 5. Relayer (ENS service, but its OpenAPI schema is shared across DAOs and is -# required for @anticapture/client codegen — the dashboard imports relayer hooks -# unconditionally — so it runs for every DAO). -start_relayer +# 5. Relayer (optional; only available for a few DAOs — do not block the rest of the stack) +RELAYER_AVAILABLE=false +if start_relayer; then + RELAYER_AVAILABLE=true +fi # 6. Gateful start_gateful @@ -313,7 +299,11 @@ else printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" fi printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" -printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" +if [ "$RELAYER_AVAILABLE" = true ]; then + printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" +else + printf " ${C_RELAYER}📡 Relayer${C_RESET} skipped (optional)\n" +fi printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" echo "" From 2c713020a8fac10ab37db701d306d6d00e165918 Mon Sep 17 00:00:00 2001 From: Pedro Binotto Date: Tue, 30 Jun 2026 14:36:44 -0300 Subject: [PATCH 17/44] fix: revert scripts alteration --- scripts/dev.sh | 30 +++++++----------------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index 13b139357..a2556ed29 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -31,6 +31,7 @@ dao_id_for() { uniswap) echo "uni" ;; gitcoin) echo "gtc" ;; scroll) echo "scr" ;; + tornado) echo "torn" ;; shutter) echo "shu" ;; compound) echo "comp" ;; *) echo "$1" ;; @@ -132,19 +133,9 @@ start_gateful() { } start_relayer() { - if [ -z "$DAO_NAME" ]; then - log "Skipping optional Relayer (no DAO selected)" - return 1 - fi - local service="$(echo "$DAO_NAME" | tr '[:upper:]' '[:lower:]')-relayer" - log "Starting optional Relayer ($service)..." - # Always run through `railway run`; if the service doesn't exist it simply - # fails to come up and wait_for_optional_port lets the stack continue. - run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s "$service" pnpm relayer dev & - if wait_for_optional_port "$PORT_RELAYER" "Relayer"; then - return 0 - fi - return 1 + export DAO_RELAYER_ENS="http://localhost:${PORT_RELAYER}" + run_with_prefix "$C_RELAYER" "📡 relayer" "" "" railway run -e dev -s ens-relayer pnpm relayer dev & + wait_for_port "$PORT_RELAYER" "Relayer" } if [ "${BASH_SOURCE[0]}" != "$0" ]; then @@ -267,11 +258,8 @@ if [ "$RUN_API" = true ]; then ) & fi -# 5. Relayer (optional; only available for a few DAOs — do not block the rest of the stack) -RELAYER_AVAILABLE=false -if start_relayer; then - RELAYER_AVAILABLE=true -fi +# 5. Relayer (only available for ENS) +start_relayer # 6. Gateful start_gateful @@ -299,11 +287,7 @@ else printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" fi printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" -if [ "$RELAYER_AVAILABLE" = true ]; then - printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" -else - printf " ${C_RELAYER}📡 Relayer${C_RESET} skipped (optional)\n" -fi +printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n" printf " ${C_DASHBOARD}📺 Dashboard${C_RESET} http://localhost:${PORT_DASHBOARD}\n" echo "" From 36992d728e562b32c87402812a54acde82092593 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 14:43:30 -0300 Subject: [PATCH 18/44] chore: changeset --- .changeset/late-eagles-pick.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/late-eagles-pick.md diff --git a/.changeset/late-eagles-pick.md b/.changeset/late-eagles-pick.md new file mode 100644 index 000000000..d0ff41185 --- /dev/null +++ b/.changeset/late-eagles-pick.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +support Tornado Cash proposal creation From 593fc57b51120f9c0bf9af2368d3fd3c6d2576ce Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 15:41:29 -0300 Subject: [PATCH 19/44] fix(api): tron governor added to non-circulating --- apps/api/src/lib/constants.ts | 44 ++++++++++++++++++- .../src/services/account-balance/listing.ts | 7 ++- apps/indexer/src/indexer/torn/erc20.ts | 29 ++++-------- apps/indexer/src/lib/constants.ts | 6 +-- 4 files changed, 60 insertions(+), 26 deletions(-) diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index 801a06636..c3086277c 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -403,8 +403,50 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, + // Governor custody (0x5efda...A1Ce) holds locked stakes — tracked as + // non-circulating, not treasury. Kept out of treasury to stay in sync with + // the indexer (where listing it in both buckets double-subtracts circulating). + [DaoIdEnum.TORN]: {}, +}; + +// Mirrors the indexer's NonCirculatingAddresses. Locked/vesting supply held by +// DAO-controlled contracts; excluded from holder listings alongside treasury +// when excludeDaoAddresses is set. +export const NonCirculatingAddresses: Record< + DaoIdEnum, + Record +> = { + [DaoIdEnum.UNI]: {}, + [DaoIdEnum.ENS]: { + "Token Timelock": "0xd7a029db2585553978190db5e85ec724aa4df23f", + }, + [DaoIdEnum.ARB]: {}, + [DaoIdEnum.AAVE]: { + "LEND to AAVE Migrator": "0x317625234562B1526Ea2FaC4030Ea499C5291de4", + }, + [DaoIdEnum.OP]: {}, + [DaoIdEnum.SHU]: {}, + [DaoIdEnum.LIL_NOUNS]: {}, + [DaoIdEnum.NOUNS]: {}, + [DaoIdEnum.GTC]: {}, + [DaoIdEnum.SCR]: {}, + [DaoIdEnum.COMP]: {}, + [DaoIdEnum.OBOL]: {}, + [DaoIdEnum.ZK]: { + "Initial Merkle Distributor": "0x66fd4fc8fa52c9bec2aba368047a0b27e24ecfe4", + "Second ZK Distributor": "0xb294F411cB52c7C6B6c0B0b61DBDf398a8b0725d", + "Third ZK Distributor": "0xf29d698e74ef1904bcfdb20ed38f9f3ef0a89e5b", + "Matter Labs Allocation": "0xa97fbc75ccbc7d4353c4d2676ed18cd0c5aaf7e6", + "Foundation Allocation": "0xd78dc27d4db8f428c67f542216a2b23663838405", + "Guardians Allocation": "0x21b27952f8621f54f3cb652630e122ec81dd2dc1", + "Security Council Allocation": "0x0ad50686c159040e57ddce137db0b63c67473450", + "ZKsync Association Allocation": + "0x0681e3808a0aa12004fb815ebb4515dc823cfbb4", + }, + [DaoIdEnum.FLUID]: {}, [DaoIdEnum.TORN]: { - Governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", }, }; diff --git a/apps/api/src/services/account-balance/listing.ts b/apps/api/src/services/account-balance/listing.ts index 8544c94f9..84e2ac4f0 100644 --- a/apps/api/src/services/account-balance/listing.ts +++ b/apps/api/src/services/account-balance/listing.ts @@ -2,7 +2,7 @@ import { Address } from "viem"; import { DaoIdEnum } from "@/lib/enums"; import { AmountFilter, DBAccountBalanceWithVariation } from "@/mappers"; -import { TreasuryAddresses } from "@/lib/constants"; +import { TreasuryAddresses, NonCirculatingAddresses } from "@/lib/constants"; export interface AccountBalanceRepositoryInterface { getAccountBalancesWithVariation( @@ -48,7 +48,10 @@ export class AccountBalanceService { totalCount: number; }> { const daoAddresses = excludeDaoAddresses - ? Object.values(TreasuryAddresses[daoId]) + ? [ + ...Object.values(TreasuryAddresses[daoId]), + ...Object.values(NonCirculatingAddresses[daoId]), + ] : []; return await this.repo.getAccountBalancesWithVariation( variationFromTimestamp, diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts index 1fc0c54cf..08dc4f26e 100644 --- a/apps/indexer/src/indexer/torn/erc20.ts +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -11,7 +11,6 @@ import { updateTotalSupply, } from "@/eventHandlers/metrics"; import { - CONTRACT_ADDRESSES, MetricTypesEnum, BurningAddresses, CEXAddresses, @@ -24,9 +23,6 @@ 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, - ); // Contracts that custody locked TORN. A balance held here is voting power // owned by the locker, not the custody contract. TORN emits no @@ -158,24 +154,13 @@ export function TORNTokenIndexer(address: Address, decimals: number) { await updateCirculatingSupply(context, daoId, address, timestamp); - // Track locks/unlocks: transfers to/from the governance contract + // Track locks/unlocks: TORN moving in/out of governance custody — the + // governor pre-v2, the TornadoVault post-v2 (GovernanceVaultUpgrade._ + // transferTokens). Both are lock sinks. Governor<->vault internal moves + // (e.g. the v2 migration) have custody on both sides and net to zero, so + // they are skipped. Governor-only accounting missed ~2.6M TORN in the Vault. 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); - } - - // Per-account voting power. A transfer into custody locks (the locker is - // `from`, gaining power); out of custody unlocks (the locker is `to`, - // losing power). Governor<->vault internal moves (e.g. the vault migration) - // have custody on both sides and net to zero, so they are skipped. const toIsCustody = lockCustodyAddresses.has(normalizedTo); const fromIsCustody = lockCustodyAddresses.has(normalizedFrom); @@ -183,6 +168,10 @@ export function TORNTokenIndexer(address: Address, decimals: number) { const locker = toIsCustody ? normalizedFrom : normalizedTo; const delta = toIsCustody ? value : -value; + // Aggregate locked (delegated) supply. + await updateDelegatedSupply(context, daoId, address, delta, timestamp); + + // Per-account voting power: the locker (`from` on lock, `to` on unlock). await ensureAccountExists(context, locker); const { votingPower } = await context.db diff --git a/apps/indexer/src/lib/constants.ts b/apps/indexer/src/lib/constants.ts index 5978be530..af55aa6a4 100644 --- a/apps/indexer/src/lib/constants.ts +++ b/apps/indexer/src/lib/constants.ts @@ -417,9 +417,9 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, - [DaoIdEnum.TORN]: { - Governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", - }, + // Governor custody (0x5efda...A1Ce) holds locked stakes, already subtracted + // via NonCirculatingAddresses[TORN].governance — not a treasury. + [DaoIdEnum.TORN]: {}, }; export const CEXAddresses: Record> = { From 1735924fd2b811db9eac96cd0647f6e9b4749e97 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 15:50:59 -0300 Subject: [PATCH 20/44] chore: trigger deploy --- apps/indexer/config/torn.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/indexer/config/torn.config.ts b/apps/indexer/config/torn.config.ts index 915c3c455..80bb3fd4e 100644 --- a/apps/indexer/config/torn.config.ts +++ b/apps/indexer/config/torn.config.ts @@ -24,8 +24,8 @@ export default createConfig({ TORNToken: { abi: TORNTokenAbi, chain: "ethereum_mainnet", - address: TORN_CONTRACTS.token.address, startBlock: TORN_CONTRACTS.token.startBlock, + address: TORN_CONTRACTS.token.address, }, TORNGovernor: { abi: TORNGovernorAbi, From 203ea725285d00e040ef684b90db338f3f3bfd63 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 15:52:29 -0300 Subject: [PATCH 21/44] chore: disable postinstall --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9498b1a45..4b2d7903a 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:e2e:headed": "turbo test:e2e:headed", "test:e2e:ui": "turbo test:e2e:ui", "changeset": "changeset", - "postinstall": "node scripts/setup-memory.mjs" + "setup:memory": "node scripts/setup-memory.mjs" }, "license": "MIT", "packageManager": "pnpm@10.10.0", From cdb00d2996a5d23ecf6d01ad8ce9e43207b8a1ae Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 16:16:12 -0300 Subject: [PATCH 22/44] test(api): offchain controllers 400 --- .../offchainProposals.integration.test.ts | 48 +++++++++++++++++-- .../proposals/offchainProposals.ts | 30 ++++++++++++ .../offchainNonVoters.integration.test.ts | 37 ++++++++++++++ .../votes/offchainVotes.integration.test.ts | 37 ++++++++++++-- .../src/controllers/votes/offchainVotes.ts | 15 ++++++ 5 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts diff --git a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts index d1e740584..75b78cb0e 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.integration.test.ts @@ -62,6 +62,14 @@ const BASE_PROPOSAL_ITEM = { strategies: [], }; +const createApp = (supportOffchain = true) => { + const repo = new OffchainProposalRepository(db); + const service = new OffchainProposalsService(repo); + const testApp = new Hono(); + offchainProposalsController(testApp, service, supportOffchain); + return testApp; +}; + beforeAll(async () => { client = new PGlite(); const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; @@ -78,13 +86,45 @@ afterAll(async () => { beforeEach(async () => { await db.delete(offchainProposals); - const repo = new OffchainProposalRepository(db); - const service = new OffchainProposalsService(repo); - app = new Hono(); - offchainProposalsController(app, service); + app = createApp(); }); describe("Offchain Proposals Controller", () => { + describe("supportOffchain=false", () => { + it("should return 400 for proposal list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal search", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/search?query=test"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal detail", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/proposal-1"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + }); + describe("GET /offchain/proposals", () => { it("should return 200 with correct response shape", async () => { const proposal = createProposal(); diff --git a/apps/api/src/controllers/proposals/offchainProposals.ts b/apps/api/src/controllers/proposals/offchainProposals.ts index f63866f48..8d4e360c6 100644 --- a/apps/api/src/controllers/proposals/offchainProposals.ts +++ b/apps/api/src/controllers/proposals/offchainProposals.ts @@ -102,9 +102,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { query, skip, limit, lean } = context.req.valid("query"); const { items, totalCount } = await service.searchProposals({ @@ -154,9 +169,24 @@ export function offchainProposals( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { lean } = context.req.valid("query"); diff --git a/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts b/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts new file mode 100644 index 000000000..f5968edd2 --- /dev/null +++ b/apps/api/src/controllers/votes/offchainNonVoters.integration.test.ts @@ -0,0 +1,37 @@ +import { OpenAPIHono as Hono } from "@hono/zod-openapi"; +import { describe, expect, it } from "vitest"; +import type { Address } from "viem"; +import { OffchainNonVotersService } from "@/services/votes/offchainNonVoters"; +import type { OffchainNonVotersRepository } from "@/repositories/votes/offchainNonVoters"; +import { offchainNonVoters as offchainNonVotersController } from "./offchainNonVoters"; + +class StubOffchainNonVotersRepository implements OffchainNonVotersRepository { + async proposalExists() { + return true; + } + + async getOffchainNonVoters() { + return []; + } + + async getOffchainNonVotersCount(_proposalId: string, _addresses?: Address[]) { + return 0; + } +} + +describe("Offchain Non-Voters Controller", () => { + it("should return 400 when supportOffchain=false", async () => { + const service = new OffchainNonVotersService( + new StubOffchainNonVotersRepository(), + ); + const app = new Hono(); + offchainNonVotersController(app, service, false); + + const res = await app.request("/offchain/proposals/proposal-1/non-voters"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); +}); diff --git a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts index caef2a131..40526a2a2 100644 --- a/apps/api/src/controllers/votes/offchainVotes.integration.test.ts +++ b/apps/api/src/controllers/votes/offchainVotes.integration.test.ts @@ -64,6 +64,14 @@ const createOffchainProposal = ( ...overrides, }); +const createApp = (supportOffchain = true) => { + const repo = new OffchainVoteRepository(db); + const service = new OffchainVotesService(repo); + const testApp = new Hono(); + offchainVotesController(testApp, service, supportOffchain); + return testApp; +}; + beforeAll(async () => { client = new PGlite(); const unifiedSchema = { ...schema, ...offchainSchema, ...generalSchema }; @@ -81,13 +89,34 @@ beforeEach(async () => { await db.delete(offchainVotes); await db.delete(offchainProposals); - const repo = new OffchainVoteRepository(db); - const service = new OffchainVotesService(repo); - app = new Hono(); - offchainVotesController(app, service); + app = createApp(); }); describe("Offchain Votes Controller", () => { + describe("supportOffchain=false", () => { + it("should return 400 for vote list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/votes"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + + it("should return 400 for proposal vote list", async () => { + app = createApp(false); + + const res = await app.request("/offchain/proposals/proposal-1/votes"); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Offchain data not supported", + }); + }); + }); + describe("GET /offchain/votes", () => { it("should return 200 with correct response shape", async () => { await db.insert(offchainProposals).values(createOffchainProposal()); diff --git a/apps/api/src/controllers/votes/offchainVotes.ts b/apps/api/src/controllers/votes/offchainVotes.ts index 6926c8ff4..7467b506f 100644 --- a/apps/api/src/controllers/votes/offchainVotes.ts +++ b/apps/api/src/controllers/votes/offchainVotes.ts @@ -100,9 +100,24 @@ export function offchainVotes( }, }, }, + 400: { + description: "Offchain data not supported", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, }, }), async (context) => { + if (!supportOffchain) { + return context.json( + ErrorResponseSchema.parse({ error: "Offchain data not supported" }), + 400, + ); + } + const { id } = context.req.valid("param"); const { skip, From 2f0aca60e1a4785af8d7f52cd81c6a3cfbac63ee Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 18:19:03 -0300 Subject: [PATCH 23/44] fix(dashboard): delegation amount being correctly parsed --- .changeset/offchain-support-guards.md | 5 ++++ .../VotingPowerHistoryTable.tsx | 29 +++++++------------ 2 files changed, 15 insertions(+), 19 deletions(-) create mode 100644 .changeset/offchain-support-guards.md diff --git a/.changeset/offchain-support-guards.md b/.changeset/offchain-support-guards.md new file mode 100644 index 000000000..9124ff68b --- /dev/null +++ b/.changeset/offchain-support-guards.md @@ -0,0 +1,5 @@ +--- +"@anticapture/api": patch +--- + +Return unsupported-offchain errors consistently across offchain proposal and vote routes. diff --git a/apps/dashboard/features/holders-and-delegates/delegate/drawer/voting-power-history/VotingPowerHistoryTable.tsx b/apps/dashboard/features/holders-and-delegates/delegate/drawer/voting-power-history/VotingPowerHistoryTable.tsx index f67fdbcc6..cb3487461 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/drawer/voting-power-history/VotingPowerHistoryTable.tsx +++ b/apps/dashboard/features/holders-and-delegates/delegate/drawer/voting-power-history/VotingPowerHistoryTable.tsx @@ -239,25 +239,16 @@ export const VotingPowerHistoryTable = ({ ); } - let amount = "0"; - if (item.delegation) { - const value = Number( - formatUnits(BigInt(item.delegation.value), decimals), - ); - amount = formatNumberUserReadable( - value > 0 && value < 1 ? 0.01 : value, - ); - } else if (item.transfer) { - const value = Number( - formatUnits(BigInt(item.transfer.value), decimals), - ); - amount = formatNumberUserReadable( - value > 0 && value < 1 ? 0.01 : value, - ); - } else { - // Auto delegation protocols wont have neither delegation nor transfer, so we use the delta - amount = item.delta; - } + // delegation/transfer carry a raw token value; auto-delegation protocols + // have neither, so fall back to the already-formatted delta. + const rawValue = item.delegation + ? Number(formatUnits(BigInt(item.delegation.value), decimals)) + : item.transfer + ? Number(formatUnits(BigInt(item.transfer.value), decimals)) + : Math.abs(Number(item.delta)); + const amount = formatNumberUserReadable( + rawValue > 0 && rawValue < 1 ? 0.01 : rawValue, + ); return (
From 2fc71740f3953d5c49eaf92d5ab7947ae821ce0f Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 18:19:24 -0300 Subject: [PATCH 24/44] fix(api): add torn voting power repository --- ...torn-voting-power-history-transfer-link.md | 6 + apps/api/cmd/index.ts | 5 +- .../src/repositories/voting-power/index.ts | 1 + .../api/src/repositories/voting-power/torn.ts | 127 +++++++++++++ .../voting-power/torn.unit.test.ts | 169 ++++++++++++++++++ apps/dashboard/shared/dao-config/torn.ts | 3 +- 6 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 .changeset/torn-voting-power-history-transfer-link.md create mode 100644 apps/api/src/repositories/voting-power/torn.ts create mode 100644 apps/api/src/repositories/voting-power/torn.unit.test.ts diff --git a/.changeset/torn-voting-power-history-transfer-link.md b/.changeset/torn-voting-power-history-transfer-link.md new file mode 100644 index 000000000..8ba679ef5 --- /dev/null +++ b/.changeset/torn-voting-power-history-transfer-link.md @@ -0,0 +1,6 @@ +--- +"@anticapture/api": patch +"@anticapture/dashboard": patch +--- + +Fix TORN historical voting power rows being rendered as bogus zero-address delegations. TORN derives voting power directly from Transfers, so each history row shares the Transfer's log index, which the generic repository's strict `<` join never matched. Added a dedicated TORN voting-power repository that links the causing event at `logIndex <= row logIndex`. Dashboard also formats the auto-delegation fallback amount instead of dumping the raw delta. diff --git a/apps/api/cmd/index.ts b/apps/api/cmd/index.ts index a97f4f5f3..932cffee6 100644 --- a/apps/api/cmd/index.ts +++ b/apps/api/cmd/index.ts @@ -68,6 +68,7 @@ import { HistoricalBalanceRepository, NFTPriceRepository, NounsVotingPowerRepository, + TORNVotingPowerRepository, TokenRepository, TransfersRepository, TreasuryRepository, @@ -236,7 +237,9 @@ const votingPowerService = wrapWithTracing( new VotingPowerService( env.DAO_ID === DaoIdEnum.NOUNS || env.DAO_ID === DaoIdEnum.LIL_NOUNS ? wrapWithTracing(new NounsVotingPowerRepository(pgClient)) - : votingPowerRepo, + : env.DAO_ID === DaoIdEnum.TORN + ? wrapWithTracing(new TORNVotingPowerRepository(pgClient)) + : votingPowerRepo, votingPowerRepo, ), ); diff --git a/apps/api/src/repositories/voting-power/index.ts b/apps/api/src/repositories/voting-power/index.ts index e1308af25..93fc95791 100644 --- a/apps/api/src/repositories/voting-power/index.ts +++ b/apps/api/src/repositories/voting-power/index.ts @@ -1,2 +1,3 @@ export * from "./nouns"; +export * from "./torn"; export * from "./general"; diff --git a/apps/api/src/repositories/voting-power/torn.ts b/apps/api/src/repositories/voting-power/torn.ts new file mode 100644 index 000000000..894c25655 --- /dev/null +++ b/apps/api/src/repositories/voting-power/torn.ts @@ -0,0 +1,127 @@ +import { gte, and, lte, desc, eq, asc, sql } from "drizzle-orm"; +import { Address } from "viem"; + +import { Drizzle, votingPowerHistory, delegation, transfer } from "@/database"; +import { DBHistoricalVotingPowerWithRelations } from "@/mappers"; + +/** + * TORN derives per-account voting power directly from lock/unlock Transfers + * (it emits no DelegateVotesChanged), so each `votingPowerHistory` row is + * written with the SAME `logIndex` as the Transfer that produced it. The + * generic repository links events with `logIndex < votingPowerHistory.logIndex`, + * which never matches a same-index event and leaves the row with no transfer — + * the dashboard then renders it as a bogus delegation from the zero address. + * + * This repository matches the causing event at `logIndex <= votingPowerHistory.logIndex` + * (closest preceding-or-equal), keeping everything else identical to the generic one. + */ +export class TORNVotingPowerRepository { + constructor(private readonly db: Drizzle) {} + + async getHistoricalVotingPowerCount( + accountId?: Address, + minDelta?: string, + maxDelta?: string, + fromDate?: number, + toDate?: number, + ): Promise { + return await this.db.$count( + votingPowerHistory, + and( + accountId ? eq(votingPowerHistory.accountId, accountId) : undefined, + minDelta + ? gte(votingPowerHistory.deltaMod, BigInt(minDelta)) + : undefined, + maxDelta + ? lte(votingPowerHistory.deltaMod, BigInt(maxDelta)) + : undefined, + fromDate + ? gte(votingPowerHistory.timestamp, BigInt(fromDate)) + : undefined, + toDate ? lte(votingPowerHistory.timestamp, BigInt(toDate)) : undefined, + ), + ); + } + + async getHistoricalVotingPowers( + skip: number, + limit: number, + orderDirection: "asc" | "desc", + orderBy: "timestamp" | "delta", + accountId?: Address, + minDelta?: string, + maxDelta?: string, + fromDate?: number, + toDate?: number, + ): Promise { + const result = await this.db + .select() + .from(votingPowerHistory) + .leftJoin( + delegation, + sql`${votingPowerHistory.transactionHash} = ${delegation.transactionHash} + AND ${delegation.logIndex} = ( + SELECT MAX(${delegation.logIndex}) + FROM ${delegation} + WHERE ${delegation.transactionHash} = ${votingPowerHistory.transactionHash} + AND ${delegation.logIndex} <= ${votingPowerHistory.logIndex} + )`, + ) + .leftJoin( + transfer, + sql`${votingPowerHistory.transactionHash} = ${transfer.transactionHash} + AND ${transfer.logIndex} = ( + SELECT MAX(${transfer.logIndex}) + FROM ${transfer} + WHERE ${transfer.transactionHash} = ${votingPowerHistory.transactionHash} + AND ${transfer.logIndex} <= ${votingPowerHistory.logIndex} + )`, + ) + .where( + and( + accountId ? eq(votingPowerHistory.accountId, accountId) : undefined, + minDelta + ? gte(votingPowerHistory.deltaMod, BigInt(minDelta)) + : undefined, + maxDelta + ? lte(votingPowerHistory.deltaMod, BigInt(maxDelta)) + : undefined, + fromDate + ? gte(votingPowerHistory.timestamp, BigInt(fromDate)) + : undefined, + toDate + ? lte(votingPowerHistory.timestamp, BigInt(toDate)) + : undefined, + ), + ) + .orderBy( + orderDirection === "asc" + ? asc( + orderBy === "timestamp" + ? votingPowerHistory.timestamp + : votingPowerHistory.deltaMod, + ) + : desc( + orderBy === "timestamp" + ? votingPowerHistory.timestamp + : votingPowerHistory.deltaMod, + ), + ) + .limit(limit) + .offset(skip); + + return result.map((row) => ({ + ...row.voting_power_history, + delegations: + row.transfers && + row.transfers?.logIndex > (row.delegations?.logIndex || 0) + ? null + : row.delegations, + transfers: + row.delegations && + row.delegations?.logIndex > (row.transfers?.logIndex || 0) + ? null + : row.transfers, + })); + } +} diff --git a/apps/api/src/repositories/voting-power/torn.unit.test.ts b/apps/api/src/repositories/voting-power/torn.unit.test.ts new file mode 100644 index 000000000..d444ca7dd --- /dev/null +++ b/apps/api/src/repositories/voting-power/torn.unit.test.ts @@ -0,0 +1,169 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { Address } from "viem"; + +import type { Drizzle } from "@/database"; +import { votingPowerHistory, delegation, transfer } from "@/database/schema"; +import * as schema from "@/database/schema"; + +import { TORNVotingPowerRepository } from "./torn"; + +type VotingPowerHistoryInsert = typeof votingPowerHistory.$inferInsert; +type DelegationInsert = typeof delegation.$inferInsert; +type TransferInsert = typeof transfer.$inferInsert; + +const TEST_ACCOUNT: Address = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TEST_ACCOUNT_2: Address = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const TEST_DAO = "TORN"; +const TX_HASH = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + +const createHistoryRow = ( + overrides: Partial = {}, +): VotingPowerHistoryInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + accountId: TEST_ACCOUNT, + votingPower: 1000n, + delta: 200n, + deltaMod: 200n, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +const createTransfer = ( + overrides: Partial = {}, +): TransferInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + tokenId: "token-1", + amount: 100n, + fromAccountId: TEST_ACCOUNT, + toAccountId: TEST_ACCOUNT_2, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +const createDelegation = ( + overrides: Partial = {}, +): DelegationInsert => ({ + transactionHash: TX_HASH, + daoId: TEST_DAO, + delegateAccountId: TEST_ACCOUNT, + delegatorAccountId: TEST_ACCOUNT_2, + delegatedValue: 0n, + timestamp: 1700000000n, + logIndex: 150, + ...overrides, +}); + +describe("TORNVotingPowerRepository", () => { + let client: PGlite; + let db: Drizzle; + let repository: TORNVotingPowerRepository; + + beforeAll(async () => { + client = new PGlite(); + db = drizzle(client, { schema }); + repository = new TORNVotingPowerRepository(db); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const { apply } = await pushSchema(schema, db as any); + await apply(); + }); + + afterAll(async () => { + await client.close(); + }); + + beforeEach(async () => { + await db.delete(votingPowerHistory); + await db.delete(delegation); + await db.delete(transfer); + }); + + // The defining TORN behavior: voting power is derived directly from the + // Transfer, so the history row shares the transfer's logIndex. The generic + // repository's strict `<` join would drop it; this one matches on `<=`. + it("links a transfer whose logIndex equals the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + await db.insert(transfer).values(createTransfer({ logIndex: 150 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.transfers).not.toBeNull(); + expect(result[0]!.transfers!.logIndex).toBe(150); + expect(result[0]!.delegations).toBeNull(); + }); + + it("links a delegation whose logIndex equals the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + await db.insert(delegation).values(createDelegation({ logIndex: 150 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.delegations).not.toBeNull(); + expect(result[0]!.delegations!.logIndex).toBe(150); + }); + + it("returns null relations when none share or precede the row logIndex", async () => { + await db + .insert(votingPowerHistory) + .values(createHistoryRow({ logIndex: 150 })); + // transfer in the same tx but AFTER the row is not the cause + await db.insert(transfer).values(createTransfer({ logIndex: 151 })); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + ); + + expect(result).toHaveLength(1); + expect(result[0]!.transfers).toBeNull(); + expect(result[0]!.delegations).toBeNull(); + }); + + it("filters by accountId", async () => { + await db.insert(votingPowerHistory).values([ + createHistoryRow({ accountId: TEST_ACCOUNT, logIndex: 150 }), + createHistoryRow({ + accountId: TEST_ACCOUNT_2, + transactionHash: + "0x0000000000000000000000000000000000000000000000000000000000000002", + logIndex: 151, + }), + ]); + + const result = await repository.getHistoricalVotingPowers( + 0, + 10, + "desc", + "timestamp", + TEST_ACCOUNT, + ); + + expect(result).toHaveLength(1); + expect(result[0]!.accountId).toBe(TEST_ACCOUNT); + }); +}); diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index 3c8d345bb..f6ebd8178 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -229,8 +229,7 @@ export const TORN: DaoConfiguration = { GOVERNANCE_IMPLEMENTATION_CONSTANTS[ GovernanceImplementationEnum.VOTING_DELAY ].description, - currentSetting: - "The Voting Delay is set to 75 seconds.", + currentSetting: "The Voting Delay is set to 75 seconds.", impact: "The Voting Delay period is extremely short. This gives delegates and stakeholders little time to coordinate their votes and for the DAO to protect itself against an attack. This poses a critical governance risk.", recommendedSetting: From 0d2da6586a227f7c5382726144be1ea9797fd25e Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 18:37:22 -0300 Subject: [PATCH 25/44] feat(indexer): compute delegation form governor --- .changeset/torn-delegation-voting-power.md | 5 + apps/indexer/src/indexer/torn/INTEGRATION.md | 14 +-- apps/indexer/src/indexer/torn/erc20.ts | 44 ++++---- apps/indexer/src/indexer/torn/governor.ts | 74 +++++++++++-- apps/indexer/src/indexer/torn/shared.ts | 106 +++++++++++++++++++ 5 files changed, 206 insertions(+), 37 deletions(-) create mode 100644 .changeset/torn-delegation-voting-power.md create mode 100644 apps/indexer/src/indexer/torn/shared.ts diff --git a/.changeset/torn-delegation-voting-power.md b/.changeset/torn-delegation-voting-power.md new file mode 100644 index 000000000..5d2528830 --- /dev/null +++ b/.changeset/torn-delegation-voting-power.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": minor +--- + +TORN: route locked voting power to the locker's delegate and move it between delegates on Delegated/Undelegated. TORN emits no DelegateVotesChanged, so per-account voting power is now fully synthesized from lock/unlock Transfers plus delegation shifts. diff --git a/apps/indexer/src/indexer/torn/INTEGRATION.md b/apps/indexer/src/indexer/torn/INTEGRATION.md index cef25e2b2..495f70dda 100644 --- a/apps/indexer/src/indexer/torn/INTEGRATION.md +++ b/apps/indexer/src/indexer/torn/INTEGRATION.md @@ -18,6 +18,7 @@ Governor voting token: Voting power comes from `lockedBalance` in the Governance - [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] Per-account voting power via synthetic `votingPowerHistory` (lock/unlock Transfers + delegation shifts — no `DelegateVotesChanged` needed) - [x] Governor proposals (ProposalCreated — custom handler with timestamp-based timing) - [x] Governor votes (Voted — binary for/against, no abstain) - [x] Proposal execution (ProposalExecuted event) @@ -25,12 +26,6 @@ Governor voting token: Voting power comes from `lockedBalance` in the Governance ## 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. @@ -90,6 +85,13 @@ Tornado Cash was sanctioned by the U.S. Treasury's OFAC in August 2022. While th | CLOSING_PERIOD | 3600 | 1 hour | | VOTE_EXTEND_TIME | 21600 | 6 hours | +### Per-account voting power (no DelegateVotesChanged) + +Voting power is `lockedBalance`, credited to whoever the locker delegates to. Since TORN emits no `DelegateVotesChanged`, both inputs are synthesized in `torn/shared.ts`: + +- **Lock/unlock** (`erc20.ts`): each account's net locked balance is tracked in an `accountBalance` row keyed by the governor address (distinct from the wallet-balance row keyed by the token). Voting power is applied to the locker's current delegate (itself if none). +- **Delegation shift** (`governor.ts` Delegated/Undelegated): the locker's locked balance moves as voting power from the old delegate to the new one — no Transfer occurs, so this is the only signal. + ### Vote handler: onConflictDoNothing The shared `voteCast()` handler uses plain inserts which trigger Ponder's `DelayedInsertError` during batch flushing (duplicate key constraint on `votes_onchain`). This is specific to Tornado Cash and occurs during backfill. The custom handler uses `onConflictDoNothing` on the vote insert to prevent crashes. The insert returns `null` on conflict, and the handler short-circuits in that case so tally/count/feed updates only run for genuinely new votes — no double-counting on replay. diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts index 08dc4f26e..4d96b7ccd 100644 --- a/apps/indexer/src/indexer/torn/erc20.ts +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -1,15 +1,16 @@ import { ponder } from "ponder:registry"; -import { accountPower, token, votingPowerHistory } from "ponder:schema"; +import { token } from "ponder:schema"; import { Address, getAddress } from "viem"; import { tokenTransfer } from "@/eventHandlers"; -import { ensureAccountExists } from "@/eventHandlers/shared"; import { updateDelegatedSupply, updateCirculatingSupply, updateSupplyMetric, updateTotalSupply, } from "@/eventHandlers/metrics"; + +import { addLockedBalance, applyVotingPower, getDelegate } from "./shared"; import { MetricTypesEnum, BurningAddresses, @@ -171,29 +172,22 @@ export function TORNTokenIndexer(address: Address, decimals: number) { // Aggregate locked (delegated) supply. await updateDelegatedSupply(context, daoId, address, delta, timestamp); - // Per-account voting power: the locker (`from` on lock, `to` on unlock). - await ensureAccountExists(context, locker); - - const { votingPower } = await context.db - .insert(accountPower) - .values({ accountId: locker, daoId, votingPower: delta }) - .onConflictDoUpdate((current) => ({ - votingPower: current.votingPower + delta, - })); - - await context.db - .insert(votingPowerHistory) - .values({ - daoId, - transactionHash: event.transaction.hash, - accountId: locker, - votingPower, - delta, - deltaMod: delta > 0n ? delta : -delta, - timestamp, - logIndex: event.log.logIndex, - }) - .onConflictDoNothing(); + // Track the locker's per-account locked balance so a later delegation can + // move the right amount of voting power (TORN has no DelegateVotesChanged). + await addLockedBalance(context, locker, delta); + + // Voting power accrues to the locker's current delegate — itself unless it + // has delegated to someone else. + const recipient = await getDelegate(context, locker); + await applyVotingPower( + context, + daoId, + recipient, + delta, + event.transaction.hash, + timestamp, + event.log.logIndex, + ); } }); diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts index 898754ac5..5c0d7c98a 100644 --- a/apps/indexer/src/indexer/torn/governor.ts +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -13,6 +13,8 @@ import { ensureAccountExists } from "@/eventHandlers/shared"; import { CONTRACT_ADDRESSES, ProposalStatus } from "@/lib/constants"; import { DaoIdEnum } from "@/lib/enums"; +import { applyVotingPower, getDelegate, getLockedBalance } from "./shared"; + const MAX_TITLE_LENGTH = 200; /** @@ -242,6 +244,36 @@ export function TORNGovernorIndexer(blockTime: number) { ponder.on("TORNGovernor:Delegated", async ({ event, context }) => { const { account, to } = event.args; + const txHash = event.transaction.hash; + const timestamp = event.block.timestamp; + const logIndex = event.log.logIndex; + + // TORN has no DelegateVotesChanged: locked TORN never moves on-chain when + // delegation changes, so shift the locker's voting power from its current + // delegate (itself if none) to the new one. + const lockedBalance = await getLockedBalance(context, account); + const prevRecipient = await getDelegate(context, account); + const newRecipient = getAddress(to); + if (lockedBalance !== 0n && prevRecipient !== newRecipient) { + await applyVotingPower( + context, + daoId, + prevRecipient, + -lockedBalance, + txHash, + timestamp, + logIndex, + ); + await applyVotingPower( + context, + daoId, + newRecipient, + lockedBalance, + txHash, + timestamp, + logIndex, + ); + } // Look up the previous delegate from accountBalance const existing = await context.db.find(accountBalance, { @@ -255,14 +287,43 @@ export function TORNGovernorIndexer(blockTime: number) { delegate: to, tokenId: TORN_TOKEN_ADDRESS, previousDelegate, - txHash: event.transaction.hash, - timestamp: event.block.timestamp, - logIndex: event.log.logIndex, + txHash, + timestamp, + logIndex, + delegatorBalance: lockedBalance, }); }); ponder.on("TORNGovernor:Undelegated", async ({ event, context }) => { const { account, from } = event.args; + const txHash = event.transaction.hash; + const timestamp = event.block.timestamp; + const logIndex = event.log.logIndex; + + // Move the locker's voting power back from `from` to itself. + const lockedBalance = await getLockedBalance(context, account); + const prevRecipient = getAddress(from); + const newRecipient = getAddress(account); + if (lockedBalance !== 0n && prevRecipient !== newRecipient) { + await applyVotingPower( + context, + daoId, + prevRecipient, + -lockedBalance, + txHash, + timestamp, + logIndex, + ); + await applyVotingPower( + context, + daoId, + newRecipient, + lockedBalance, + txHash, + timestamp, + logIndex, + ); + } // Undelegation: delegate reverts to self, previous delegate was `from` await delegateChanged(context, daoId, { @@ -270,9 +331,10 @@ export function TORNGovernorIndexer(blockTime: number) { delegate: account, tokenId: TORN_TOKEN_ADDRESS, previousDelegate: from, - txHash: event.transaction.hash, - timestamp: event.block.timestamp, - logIndex: event.log.logIndex, + txHash, + timestamp, + logIndex, + delegatorBalance: lockedBalance, }); }); } diff --git a/apps/indexer/src/indexer/torn/shared.ts b/apps/indexer/src/indexer/torn/shared.ts new file mode 100644 index 000000000..d39f1a663 --- /dev/null +++ b/apps/indexer/src/indexer/torn/shared.ts @@ -0,0 +1,106 @@ +import { Context } from "ponder:registry"; +import { + accountBalance, + accountPower, + votingPowerHistory, +} from "ponder:schema"; +import { Address, getAddress, Hex, zeroAddress } from "viem"; + +import { ensureAccountExists } from "@/eventHandlers/shared"; +import { CONTRACT_ADDRESSES } from "@/lib/constants"; +import { DaoIdEnum } from "@/lib/enums"; + +// Per-account locked TORN lives in the Governance contract, not the ERC20. We +// store it in an `accountBalance` row keyed by the governor address so it never +// collides with the wallet-balance row (keyed by the TORN token address). +const TORN_LOCK_KEY = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].governor.address, +); + +const TORN_TOKEN_ADDRESS = getAddress( + CONTRACT_ADDRESSES[DaoIdEnum.TORN].token.address, +); + +/** Net TORN an account has locked into governance — drives its voting power. */ +export const getLockedBalance = async ( + context: Context, + account: Address, +): Promise => { + const row = await context.db.find(accountBalance, { + accountId: getAddress(account), + tokenId: TORN_LOCK_KEY, + }); + return row?.balance ?? 0n; +}; + +/** Apply a delta to an account's locked balance. */ +export const addLockedBalance = async ( + context: Context, + account: Address, + delta: bigint, +): Promise => { + await ensureAccountExists(context, account); + await context.db + .insert(accountBalance) + .values({ + accountId: getAddress(account), + tokenId: TORN_LOCK_KEY, + balance: delta, + }) + .onConflictDoUpdate((current) => ({ + balance: current.balance + delta, + })); +}; + +/** Whoever an account currently delegates to (itself when never delegated). */ +export const getDelegate = async ( + context: Context, + account: Address, +): Promise
=> { + const normalized = getAddress(account); + const row = await context.db.find(accountBalance, { + accountId: normalized, + tokenId: TORN_TOKEN_ADDRESS, + }); + return !row || row.delegate === zeroAddress ? normalized : row.delegate; +}; + +/** + * Add `delta` voting power to `account`: bump accountPower and append a + * votingPowerHistory row. TORN emits no DelegateVotesChanged, so every voting + * power change — lock/unlock and delegation shifts alike — is synthesized here. + */ +export const applyVotingPower = async ( + context: Context, + daoId: DaoIdEnum, + account: Address, + delta: bigint, + txHash: Hex, + timestamp: bigint, + logIndex: number, +): Promise => { + if (delta === 0n) return; + const normalized = getAddress(account); + await ensureAccountExists(context, normalized); + + const { votingPower } = await context.db + .insert(accountPower) + .values({ accountId: normalized, daoId, votingPower: delta }) + .onConflictDoUpdate((current) => ({ + votingPower: current.votingPower + delta, + })); + + await context.db + .insert(votingPowerHistory) + .values({ + daoId, + transactionHash: txHash, + accountId: normalized, + votingPower, + delta, + deltaMod: delta > 0n ? delta : -delta, + timestamp, + logIndex, + }) + .onConflictDoNothing(); +}; From 96c0f22669da67a2783f46f1f16a3c0dc3b42309 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 19:31:25 -0300 Subject: [PATCH 26/44] chore: enable preview url on the pr --- .github/workflows/tests.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 059555cc8..04b7c8414 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -138,6 +138,8 @@ jobs: needs: [configure-vercel-preview, wait-for-gateful] if: needs.configure-vercel-preview.outputs.configured == 'true' runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - name: Checkout repository @@ -157,6 +159,7 @@ jobs: run: echo "name=$(git log -1 --format='%an')" >> "$GITHUB_OUTPUT" - name: Trigger Vercel preview redeploy + id: deploy env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }} @@ -181,8 +184,11 @@ jobs: # AND a runtime env (--env): the /api/gateful/* proxy route reads # process.env.ANTICAPTURE_API_URL at request time, so --build-env alone # would build against the right URL but serve a stale/missing one. + # `vercel deploy` prints the deployment URL to stdout; capture it so the + # step below can surface it on the PR (the CLI deploy path doesn't get + # Vercel's native Git-integration check/comment). run: | - npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \ + url=$(npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \ --build-env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ --env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ --meta githubCommitSha="${GIT_SHA}" \ @@ -196,7 +202,15 @@ jobs: --meta githubOrg="${GIT_ORG}" \ --meta githubRepo="${GIT_REPO}" \ --meta githubRepoId="${GIT_REPO_ID}" \ - --meta githubDeployment=1 + --meta githubDeployment=1) + echo "url=${url}" >> "$GITHUB_OUTPUT" + + - name: Comment preview URL on PR + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: vercel-preview + message: | + 🔍 **Vercel preview:** ${{ steps.deploy.outputs.url }} changeset-path-filter: name: Detect changeset requirement From 412b9e87b11b02b5de0dfb1d21d838af53242594 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Tue, 30 Jun 2026 19:46:50 -0300 Subject: [PATCH 27/44] fix(dashboard): allow vote change and disable abstain option --- .changeset/torn-vote-options.md | 5 ++++ .../components/modals/VotingModal.tsx | 23 +++++++++++-------- .../proposal-overview/ProposalHeader.tsx | 13 +++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 .changeset/torn-vote-options.md diff --git a/.changeset/torn-vote-options.md b/.changeset/torn-vote-options.md new file mode 100644 index 000000000..be4c390eb --- /dev/null +++ b/.changeset/torn-vote-options.md @@ -0,0 +1,5 @@ +--- +"@anticapture/dashboard": minor +--- + +Make TORN vote recasting reachable (show "Change your vote" on already-voted onchain proposals when the DAO allows changing votes) and hide the Abstain option for Tornado Cash, whose binary governor rejects abstain votes. diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx index 393a486b3..872078234 100644 --- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx +++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx @@ -22,7 +22,7 @@ import { useGaslessEligibility, useRelayerConfig, } from "@/shared/hooks/useGaslessRelayer"; -import type { DaoIdEnum } from "@/shared/types/daos"; +import { DaoIdEnum } from "@/shared/types/daos"; import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadable"; interface VotingModalProps { @@ -52,6 +52,9 @@ export const VotingModal = ({ const { isMobile } = useScreenSize(); + // Tornado Cash's governor uses castVote(uint256,bool) — binary only, no abstain. + const supportsAbstain = daoId !== DaoIdEnum.TORN; + // Parse user's voting power to BigInt for calculations const userVotingPowerBigInt = BigInt(rawVotingPower || "0"); @@ -248,14 +251,16 @@ export const VotingModal = ({ /> {/* Abstain vote */} - + {supportsAbstain && ( + + )}
diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx index 693cfc4f7..317908e17 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx @@ -10,6 +10,7 @@ import { OffchainVoteLabelChip } from "@/features/governance/components/proposal import { BadgeStatus, Button } from "@/shared/components"; import { ConnectWalletCustom } from "@/shared/components/wallet/ConnectWalletCustom"; import { WhitelabelConnectWallet } from "@/shared/components/wallet/WhitelabelConnectWallet"; +import daoConfigByDaoId from "@/shared/dao-config"; import { useGaslessEligibility } from "@/shared/hooks/useGaslessRelayer"; import { DaoIdEnum } from "@/shared/types/daos"; import { getDaoGovernanceListPath } from "@/shared/utils/whitelabel"; @@ -118,10 +119,22 @@ const ProposalHeaderAction = ({ return null; } + const canChangeVote = + daoConfigByDaoId[daoIdEnum]?.daoOverview.rules?.changeVote ?? false; + return (
+ {isOngoing && canChangeVote && ( + + )}
); } From 3129964ca6ff836f387cff893a9cc9fd1fbf806d Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:07:51 -0300 Subject: [PATCH 28/44] fix(dashboard): uppercase dao id on route redirect --- apps/dashboard/shared/utils/whitelabel.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/shared/utils/whitelabel.ts b/apps/dashboard/shared/utils/whitelabel.ts index e0f676062..948a6f354 100644 --- a/apps/dashboard/shared/utils/whitelabel.ts +++ b/apps/dashboard/shared/utils/whitelabel.ts @@ -48,9 +48,12 @@ export const getWhitelabelBasePath = ({ pathname: string; }) => { const daoSlug = daoId.toLowerCase(); - const normalizedPathname = pathname.startsWith("/") - ? pathname - : `/${pathname}`; + // Match case-insensitively: routes like /ENS/... must resolve the same as + // /ens/..., otherwise basePath falls through to "" and whitelabel daos emit + // a dao-less /proposals/{id} path that 404s on the main domain. + const normalizedPathname = ( + pathname.startsWith("/") ? pathname : `/${pathname}` + ).toLowerCase(); const internalBasePath = `/whitelabel/${daoSlug}`; return normalizedPathname === `/${daoSlug}` || From 6671b7ca265b1d16d96ca8afcdf55476680d897e Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:44:33 -0300 Subject: [PATCH 29/44] chore: add local dev skill and --rw-api dev script flag --- .claude/skills/local-dev-stack/SKILL.md | 61 +++++++++++++++++++++++++ scripts/dev.sh | 18 ++++++-- 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 .claude/skills/local-dev-stack/SKILL.md diff --git a/.claude/skills/local-dev-stack/SKILL.md b/.claude/skills/local-dev-stack/SKILL.md new file mode 100644 index 000000000..ec721a559 --- /dev/null +++ b/.claude/skills/local-dev-stack/SKILL.md @@ -0,0 +1,61 @@ +--- +name: local-dev +description: Use when you need to hit the running local stack (API, gateful, dashboard, indexer) to test or verify a change. Covers scripts/dev.sh, hot reload, and the ports each service listens on. +--- + +# Local Dev Stack + +The local stack is started with `scripts/dev.sh` (usually already running — check the +ports before starting your own). **All services hot-reload on file save** — after +editing source you do NOT need to restart; just re-hit the endpoint. + +## Start it + +```bash +./scripts/dev.sh # e.g. tornado, uniswap, gitcoin, scroll, shutter, compound +./scripts/dev.sh --indexer # also run the indexer for that DAO +./scripts/dev.sh --debug-api # wait for API from IDE debugger instead of spawning it +./scripts/dev.sh # no DAO → API skipped, uses DAO_API_* from .env +``` + +### Railway env (per service) + +By **default all services run locally** (env from `.env`). Opt into `railway run -e dev` +env injection per service: + +| Flag | API | Gateful | +| -------------- | ------- | ------- | +| _(none)_ | local | local | +| `--rw-api` | railway | local | +| `--rw-gateful` | local | railway | +| `--rw` | railway | railway | + +`--rw-api` falls back to `.env` if the Railway service isn't found. Relayer and +Address Enrichment always use `railway run` when available. + +On startup it **kills anything already on the ports below**, so a second run replaces +the first. + +## Ports + +| Service | Port | URL | Notes | +| ------------------ | ----- | ---------------------- | ----------------------------------- | +| API | 42069 | http://localhost:42069 | only when a `` is passed | +| Indexer | 42070 | http://localhost:42070 | only with `--indexer` | +| Gateful (gateway) | 4001 | http://localhost:4001 | always | +| Dashboard | 3000 | http://localhost:3000 | always | +| Address Enrichment | 3001 | http://localhost:3001 | optional, skipped if no Railway svc | +| Relayer (ENS) | 3002 | http://localhost:3002 | ENS only | + +Client SDK codegen runs in watch mode (no port) and regenerates on API/gateful changes. + +## Testing tips + +- **Test the API directly** at `http://localhost:42069` — OpenAPI/REST. +- **Test the aggregated surface** through gateful at `http://localhost:4001`. +- The DAO is identified by short id in env: `DAO_API_` (e.g. `DAO_API_TORN`, + `DAO_API_UNI`). dev.sh exports these automatically. +- When the API restarts, a watchdog touches `apps/gateful/src/_dev-reload.ts` so gateful + reloads and re-points at the recovered API. + +Stop everything with Ctrl+C (dev.sh traps it and kills the whole process group). diff --git a/scripts/dev.sh b/scripts/dev.sh index a2556ed29..4468e5632 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -USE_RAILWAY=false +# Per-service railway toggles. Default: everything local; opt into railway per service. +RW_API=false +RW_GATEFUL=false RUN_INDEXER=false DEBUG_API=false DAO_NAME="" @@ -9,7 +11,9 @@ DAO_NAME="" # Parse arguments for arg in "$@"; do case "$arg" in - --rw) USE_RAILWAY=true ;; + --rw) RW_API=true; RW_GATEFUL=true ;; # both services via railway + --rw-api) RW_API=true ;; # API via railway (.env fallback) + --rw-gateful) RW_GATEFUL=true ;; # gateful via railway --indexer) RUN_INDEXER=true ;; --debug-api) DEBUG_API=true ;; *) DAO_NAME="$arg" ;; @@ -151,7 +155,7 @@ cleanup() { } trap cleanup INT TERM EXIT -# Wrap a command with `railway run` for env injection when --rw flag is set +# Wrap gateful with `railway run` for env injection when its railway toggle is set railway_run() { local -a overrides=() while [[ "${1:-}" == *=* ]]; do @@ -161,7 +165,7 @@ railway_run() { local service=$1 shift - if [ "$USE_RAILWAY" = true ]; then + if [ "$RW_GATEFUL" = true ]; then log "railway_run: railway run -e dev -s $service $*" railway run -e dev -s "$service" "$@" else @@ -217,7 +221,11 @@ if [ "$DEBUG_API" = true ] && [ "$RUN_API" = true ]; then export "DAO_API_${DAO_ID_UPPER}=http://localhost:${PORT_API}" elif [ "$RUN_API" = true ]; then log "Starting API for $DAO_NAME..." - run_with_prefix "$C_API" "🐙 api" "" "" railway_run_api "${DAO_NAME}-api" pnpm api dev -- "$DAO_NAME" & + if [ "$RW_API" = true ]; then + run_with_prefix "$C_API" "🐙 api" "" "" railway_run_api "${DAO_NAME}-api" pnpm api dev -- "$DAO_NAME" & + else + run_with_prefix "$C_API" "🐙 api" "" "" pnpm api dev -- "$DAO_NAME" & + fi wait_for_port "$PORT_API" "API" DAO_ID_UPPER=$(echo "$DAO_ID" | tr '[:lower:]' '[:upper:]') From 27aa217aaf17a01017d35319112c7403cdfb7250 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:45:30 -0300 Subject: [PATCH 30/44] chore(dashboard): remove attack exposure from torn --- apps/dashboard/e2e/dao-overview.spec.ts | 9 +++ .../dao-overview/DaoOverviewSection.tsx | 80 ++++++++++--------- apps/dashboard/shared/dao-config/torn.ts | 34 +------- 3 files changed, 52 insertions(+), 71 deletions(-) diff --git a/apps/dashboard/e2e/dao-overview.spec.ts b/apps/dashboard/e2e/dao-overview.spec.ts index e61854479..bea628bc4 100644 --- a/apps/dashboard/e2e/dao-overview.spec.ts +++ b/apps/dashboard/e2e/dao-overview.spec.ts @@ -47,6 +47,15 @@ test.describe("DAO Overview page (/ens)", () => { }); }); + test("hides resilience and attack exposure section when both are unavailable", async ({ + goto, + page, + }) => { + await goto("/torn"); + await expect(page.locator("text=RESILIENCE STAGES")).toHaveCount(0); + await expect(page.locator("text=ATTACK EXPOSURE")).toHaveCount(0); + }); + test("shows attack profitability chart or empty state", async ({ goto, page, diff --git a/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx b/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx index 2ec07c2b7..c020678c0 100644 --- a/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx +++ b/apps/dashboard/features/dao-overview/DaoOverviewSection.tsx @@ -44,6 +44,8 @@ export const DaoOverviewSection = ({ daoId }: { daoId: DaoIdEnum }) => { }); const daoRiskAreas = getDaoRiskAreas(daoId); + const hasResilienceOrAttackExposure = + daoConfig.resilienceStages || daoConfig.attackExposure; const riskAreas = { title: "Attack Exposure", risks: Object.entries(daoRiskAreas).map(([name, info]) => ({ @@ -101,57 +103,59 @@ export const DaoOverviewSection = ({ daoId }: { daoId: DaoIdEnum }) => {
)} -
-
- {daoConfig.resilienceStages ? ( + {hasResilienceOrAttackExposure && ( +
+
+ {daoConfig.resilienceStages ? ( + }> + + + ) : ( + + )} +
+
+ +
+ {daoConfig.attackExposure ? ( }> - { + router.push(`/${daoId.toLowerCase()}/risk-analysis`); + }} + variant={RiskAreaCardEnum.DAO_OVERVIEW} + className="grid h-full grid-cols-2 gap-2 px-5 lg:px-0" currentDaoStage={currentDaoStage} - daoConfig={daoConfig} - context="overview" /> ) : ( )} +
+ +
-
- -
- {daoConfig.attackExposure ? ( - }> - { - router.push(`/${daoId.toLowerCase()}/risk-analysis`); - }} - variant={RiskAreaCardEnum.DAO_OVERVIEW} - className="grid h-full grid-cols-2 gap-2 px-5 lg:px-0" - currentDaoStage={currentDaoStage} - /> - - ) : ( - - )} -
- -
-
+ )} {currentDaoStage !== Stage.UNKNOWN && (
}> diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index f6ebd8178..f97be29d6 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -6,11 +6,7 @@ import { GOVERNANCE_IMPLEMENTATION_CONSTANTS } from "@/shared/constants/governan 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"; +import { RiskLevel, GovernanceImplementationEnum } from "@/shared/types/enums"; export const TORN: DaoConfiguration = { name: "Tornado Cash", @@ -291,34 +287,6 @@ export const TORN: DaoConfiguration = { }, }, }, - attackExposure: { - defenseAreas: { - [RiskAreaEnum.SPAM_RESISTANCE]: { - description: - "Proposal submissions are unrestricted and there is no voting subsidy, significantly reducing resistance to sustained proposal spam and lowering defensive participation.", - }, - [RiskAreaEnum.ECONOMIC_SECURITY]: { - description: - "The treasury managed by the DAO creates financial incentives for attack. Without a Security Council or veto mechanism, economic risk is elevated.", - }, - [RiskAreaEnum.SAFEGUARDS]: { - description: - "Proposals cannot be canceled at all in Tornado Cash governance, and there is no Security Council or veto strategy to block malicious proposals.", - }, - [RiskAreaEnum.CONTRACT_SAFETY]: { - description: - "Audited contracts and flash loan protections provide a solid foundation for contract safety.", - }, - [RiskAreaEnum.RESPONSE_TIME]: { - description: - "An extremely short voting delay leaves little time for review or coordination, increasing the risk of rushed or unchallenged governance decisions.", - }, - [RiskAreaEnum.GOV_FRONTEND_RESILIENCE]: { - description: - "Interface protections are present but not fully hardened (e.g. not served from immutable/decentralized storage), resulting in moderate governance interface risk. Mutable votes do allow recovery if a front-end compromise is caught during the voting window.", - }, - }, - }, resilienceStages: false, tokenDistribution: true, dataTables: true, From 14543297f18e5992825b0a31f492ec78bc7fa935 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:47:08 -0300 Subject: [PATCH 31/44] fix(indexer): handle governor vault transactions aggregation to delegated supply --- ...rn-treasury-transfers-phantom-voting-power.md | 5 +++++ .../ProposalActionsInfoCard.tsx | 2 +- apps/indexer/src/indexer/torn/erc20.ts | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .changeset/torn-treasury-transfers-phantom-voting-power.md diff --git a/.changeset/torn-treasury-transfers-phantom-voting-power.md b/.changeset/torn-treasury-transfers-phantom-voting-power.md new file mode 100644 index 000000000..6301485da --- /dev/null +++ b/.changeset/torn-treasury-transfers-phantom-voting-power.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": patch +--- + +Fix TORN voting power going negative from treasury transfers. TORN derives per-account voting power from lock/unlock Transfers in/out of the governor, but TORN also flows through the governor for treasury purposes (proposal executions, batch grant payouts, the governor↔vault migration). Those were misread as user unlocks and subtracted voting power from recipients that never locked (e.g. proposal #6 funding a staking pool booked a -120k unlock against a contract with zero locked balance), producing negative voting power and phantom locked balances. A custody transfer is now only counted as a lock/unlock when its counterparty is the transaction sender, which is true for genuine `lock()`/`unlock()` calls and false for treasury flows. diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx index e7d177be2..a6b00d5cf 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalActionsInfoCard.tsx @@ -26,7 +26,7 @@ export const ProposalActionsInfoCard = ({

diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts index 4d96b7ccd..9515c4914 100644 --- a/apps/indexer/src/indexer/torn/erc20.ts +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -169,6 +169,22 @@ export function TORNTokenIndexer(address: Address, decimals: number) { const locker = toIsCustody ? normalizedFrom : normalizedTo; const delta = toIsCustody ? value : -value; + // Only a genuine lock()/unlock() call moves voting power, and in that case + // the locker is always the transaction sender: lock pulls TORN from + // msg.sender, unlock sends it back to msg.sender. TORN also flows through + // the governor for treasury purposes — proposal executions funding a + // contract, batch grant payouts, the governor<->vault migration — where + // the custody counterparty is some arbitrary address that is NOT the tx + // sender. Counting those as locks/unlocks mints/burns phantom voting power + // (e.g. proposal #6 funding a staking pool booked a -120k "unlock" against + // a contract that never locked; treasury feeders got phantom locks). Skip + // any custody transfer whose counterparty isn't the tx sender. + // ponytail: misses locks routed via a Safe/relayer (counterparty != tx + // sender) — both their lock and unlock are skipped so it stays consistent + // (undercount, never negative). Tighten with a Governance call-trace check + // if Safe-locked TORN needs to be captured. + if (locker !== getAddress(event.transaction.from)) return; + // Aggregate locked (delegated) supply. await updateDelegatedSupply(context, daoId, address, delta, timestamp); From 1a91eb56fce13083adb903b28afed971f28fdfb7 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:50:40 -0300 Subject: [PATCH 32/44] fix(indexer): handle torn vote mutability --- .changeset/torn-vote-change-mutability.md | 5 + apps/indexer/src/indexer/torn/governor.ts | 134 +++++++++++++++------- 2 files changed, 98 insertions(+), 41 deletions(-) create mode 100644 .changeset/torn-vote-change-mutability.md diff --git a/.changeset/torn-vote-change-mutability.md b/.changeset/torn-vote-change-mutability.md new file mode 100644 index 000000000..ed3b8dd96 --- /dev/null +++ b/.changeset/torn-vote-change-mutability.md @@ -0,0 +1,5 @@ +--- +"@anticapture/indexer": patch +--- + +Fix TORN votes not reflecting vote changes. TORN's governor lets a voter re-cast to change their vote, but the `Voted` handler used `onConflictDoNothing` on the unique `(voter, proposal)` row and dropped every subsequent cast — so a voter who switched from For to Against still showed as For and the proposal tally kept the stale vote. The handler now overwrites the existing row and moves the voting power between the for/against buckets on a change, while still treating an exact re-processed log (backfill/reorg) as a no-op. Requires a reindex to repopulate. diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts index 5c0d7c98a..16e7dae5f 100644 --- a/apps/indexer/src/indexer/torn/governor.ts +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -156,10 +156,10 @@ export function TORNGovernorIndexer(blockTime: number) { }); /** - * 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. + * Voted — TORN's governor lets a voter re-cast to CHANGE their vote, so the + * unique (voter, proposal) row must be mutable: overwrite it and move the + * voting power between the for/against buckets. An exact re-processing of the + * same log (backfill/reorg) is a no-op so tallies aren't double-counted. * * bool support mapped: true→1 (for), false→0 (against). No reason field. */ @@ -168,48 +168,100 @@ export function TORNGovernorIndexer(blockTime: number) { const proposalIdStr = proposalId.toString(); const supportNum = support ? 1 : 0; const normalizedVoter = getAddress(voter); + const txHash = event.transaction.hash; + const timestamp = event.block.timestamp; + const logIndex = event.log.logIndex; await ensureAccountExists(context, voter); - // onConflictDoNothing prevents DelayedInsertError from Ponder's batch flush. - // Returns null on conflict (duplicate vote during replay/backfill) — in that - // case skip all downstream updates so tallies/counts aren't double-counted. - const inserted = await context.db - .insert(votesOnchain) - .values({ - txHash: event.transaction.hash, - daoId, - proposalId: proposalIdStr, - voterAccountId: normalizedVoter, - support: supportNum.toString(), - votingPower: votes, - reason: "", - timestamp: event.block.timestamp, - logIndex: event.log.logIndex, - }) - .onConflictDoNothing(); - - if (!inserted) return; + const existing = await context.db.find(votesOnchain, { + voterAccountId: normalizedVoter, + proposalId: proposalIdStr, + }); - 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, - })); + // Exact same log replayed during backfill/reorg — no-op. + if ( + existing && + existing.txHash === txHash && + existing.logIndex === logIndex + ) { + return; + } - await context.db - .update(proposalsOnchain, { id: proposalIdStr }) - .set((current) => ({ - againstVotes: current.againstVotes + (supportNum === 0 ? votes : 0n), - forVotes: current.forVotes + (supportNum === 1 ? votes : 0n), - })); + if (existing) { + // Vote change: overwrite the row and shift power from the old support + // bucket to the new one. + const prevSupport = Number(existing.support); + const prevVotes = existing.votingPower; + + await context.db + .update(votesOnchain, { + voterAccountId: normalizedVoter, + proposalId: proposalIdStr, + }) + .set({ + txHash, + support: supportNum.toString(), + votingPower: votes, + timestamp, + logIndex, + }); + + await context.db + .update(proposalsOnchain, { id: proposalIdStr }) + .set((current) => ({ + againstVotes: + current.againstVotes - + (prevSupport === 0 ? prevVotes : 0n) + + (supportNum === 0 ? votes : 0n), + forVotes: + current.forVotes - + (prevSupport === 1 ? prevVotes : 0n) + + (supportNum === 1 ? votes : 0n), + })); + + // A changed vote refreshes the timestamp but isn't new participation. + await context.db + .update(accountPower, { accountId: normalizedVoter }) + .set({ lastVoteTimestamp: timestamp }); + } else { + // Brand-new vote. onConflictDoNothing guards against Ponder's + // DelayedInsertError during batch flushing on backfill. + await context.db + .insert(votesOnchain) + .values({ + txHash, + daoId, + proposalId: proposalIdStr, + voterAccountId: normalizedVoter, + support: supportNum.toString(), + votingPower: votes, + reason: "", + timestamp, + logIndex, + }) + .onConflictDoNothing(); + + await context.db + .insert(accountPower) + .values({ + accountId: normalizedVoter, + daoId, + votesCount: 1, + lastVoteTimestamp: timestamp, + }) + .onConflictDoUpdate((current) => ({ + votesCount: current.votesCount + 1, + lastVoteTimestamp: timestamp, + })); + + await context.db + .update(proposalsOnchain, { id: proposalIdStr }) + .set((current) => ({ + againstVotes: current.againstVotes + (supportNum === 0 ? votes : 0n), + forVotes: current.forVotes + (supportNum === 1 ? votes : 0n), + })); + } const proposal = await context.db.find(proposalsOnchain, { id: proposalIdStr, From 258eaefd0e4ed07f13c530b1b27b21f850ebe48c Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:55:23 -0300 Subject: [PATCH 33/44] chore(dashboard): change delegated to locked lable --- .../dao-overview/components/DaoOverviewHeaderMetrics.tsx | 4 +++- .../features/dao-overview/utils/votableSupplyLabel.ts | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/features/dao-overview/utils/votableSupplyLabel.ts diff --git a/apps/dashboard/features/dao-overview/components/DaoOverviewHeaderMetrics.tsx b/apps/dashboard/features/dao-overview/components/DaoOverviewHeaderMetrics.tsx index e5adb0d6a..43fb05682 100644 --- a/apps/dashboard/features/dao-overview/components/DaoOverviewHeaderMetrics.tsx +++ b/apps/dashboard/features/dao-overview/components/DaoOverviewHeaderMetrics.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { DaoOverviewHeader } from "@/features/dao-overview/components/DaoOverviewHeader"; import { DaoOverviewMetricCard } from "@/features/dao-overview/components/DaoOverviewMetricCard"; import { useDaoOverviewData } from "@/features/dao-overview/hooks/useDaoOverviewData"; +import { getVotableSupplyLabel } from "@/features/dao-overview/utils/votableSupplyLabel"; import { BadgeStatus, TooltipInfo } from "@/shared/components"; import { Tooltip } from "@/shared/components/design-system/tooltips/Tooltip"; import type { DaoConfiguration } from "@/shared/dao-config/types"; @@ -191,6 +192,7 @@ export const DaoOverviewHeaderMetrics = ({ ); const quorumGapText = getQuorumGapText(quorumGap); + const votableSupplyLabel = getVotableSupplyLabel(daoId); const treasuryMetrics = getTreasuryMetrics( daoId, @@ -226,7 +228,7 @@ export const DaoOverviewHeaderMetrics = ({ Review needed ) : ( - `${formattedValues.delegatedSupply} ${daoId} delegated` + `${formattedValues.delegatedSupply} ${daoId} ${votableSupplyLabel}` ) } subText={ diff --git a/apps/dashboard/features/dao-overview/utils/votableSupplyLabel.ts b/apps/dashboard/features/dao-overview/utils/votableSupplyLabel.ts new file mode 100644 index 000000000..f6030d431 --- /dev/null +++ b/apps/dashboard/features/dao-overview/utils/votableSupplyLabel.ts @@ -0,0 +1,4 @@ +import { DaoIdEnum } from "@/shared/types/daos"; + +export const getVotableSupplyLabel = (daoId: string) => + daoId === DaoIdEnum.TORN ? "locked" : "delegated"; From d3dbc1edec777b61c4a9be197c705fee0ee0243f Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 10:57:53 -0300 Subject: [PATCH 34/44] chore: add contents read permission to tests gh action --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 04b7c8414..4e11750e4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -140,6 +140,7 @@ jobs: runs-on: ubuntu-latest permissions: pull-requests: write + contents: read steps: - name: Checkout repository From 7923fb522bcd5c1ca5df9b18a5b7aa18285e08b6 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 11:10:00 -0300 Subject: [PATCH 35/44] fix(dashboard): torn delegate button gate --- .../delegate/DelegateButton.tsx | 26 ++++----- .../getDelegationReadContractConfig.test.ts | 31 +++++++++++ .../utils/getDelegationReadContractConfig.ts | 53 +++++++++++++++++++ apps/dashboard/shared/hooks/useDelegators.ts | 4 +- 4 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.test.ts create mode 100644 apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.ts diff --git a/apps/dashboard/features/holders-and-delegates/delegate/DelegateButton.tsx b/apps/dashboard/features/holders-and-delegates/delegate/DelegateButton.tsx index c6b4d4798..187c4b1ef 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/DelegateButton.tsx +++ b/apps/dashboard/features/holders-and-delegates/delegate/DelegateButton.tsx @@ -7,26 +7,19 @@ import type { Address } from "viem"; import { useAccount, useReadContract } from "wagmi"; import { DelegationModal } from "@/features/holders-and-delegates/delegate/DelegationModal"; +import { + DelegationReadAbi, + getDelegationReadContractConfig, +} from "@/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig"; import { showCustomToast } from "@/features/governance/utils/showCustomToast"; import { BadgeStatus, Button } from "@/shared/components"; import type { ButtonSize, ButtonVariant, } from "@/shared/components/design-system/buttons/types"; -import daoConfigByDaoId from "@/shared/dao-config"; import { useGaslessEligibility } from "@/shared/hooks/useGaslessRelayer"; import type { DaoIdEnum } from "@/shared/types/daos"; -const ERC20VotesAbi = [ - { - inputs: [{ internalType: "address", name: "account", type: "address" }], - name: "delegates", - outputs: [{ internalType: "address", name: "", type: "address" }], - stateMutability: "view", - type: "function", - }, -] as const; - interface DelegateButtonProps { delegateAddress: Address; daoId: DaoIdEnum; @@ -45,15 +38,14 @@ export const DelegateButton = ({ const [waitingForConnection, setWaitingForConnection] = useState(false); const [delegationModalOpen, setDelegationModalOpen] = useState(false); - const tokenAddress = daoConfigByDaoId[daoId]?.daoOverview?.contracts - ?.token as Address | undefined; + const delegationReadConfig = getDelegationReadContractConfig(daoId); const { data: currentDelegatee, refetch } = useReadContract({ - abi: ERC20VotesAbi, - address: tokenAddress, - functionName: "delegates", + abi: DelegationReadAbi, + address: delegationReadConfig.address, + functionName: delegationReadConfig.functionName, args: userAddress ? [userAddress] : undefined, - query: { enabled: !!userAddress && !!tokenAddress }, + query: { enabled: !!userAddress && !!delegationReadConfig.address }, }); const { isEligible: isGaslessEligible } = useGaslessEligibility( diff --git a/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.test.ts b/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.test.ts new file mode 100644 index 000000000..8cf311901 --- /dev/null +++ b/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.test.ts @@ -0,0 +1,31 @@ +import { getDelegationReadContractConfig } from "@/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig"; +import daoConfigByDaoId from "@/shared/dao-config"; +import { DaoIdEnum } from "@/shared/types/daos"; + +describe("getDelegationReadContractConfig", () => { + test("returns TORN governor delegatedTo read config", () => { + expect(getDelegationReadContractConfig(DaoIdEnum.TORN)).toEqual({ + address: daoConfigByDaoId.TORN.daoOverview.contracts.governor, + functionName: "delegatedTo", + }); + }); + + test("returns token delegates read config for non-TORN DAOs", () => { + expect(getDelegationReadContractConfig(DaoIdEnum.UNISWAP)).toEqual({ + address: daoConfigByDaoId[DaoIdEnum.UNISWAP].daoOverview.contracts.token, + functionName: "delegates", + }); + }); + + test("returns no address when a single-address contract target is missing", () => { + expect(getDelegationReadContractConfig(DaoIdEnum.TORN, {})).toEqual({ + address: undefined, + functionName: "delegatedTo", + }); + + expect(getDelegationReadContractConfig(DaoIdEnum.UNISWAP, {})).toEqual({ + address: undefined, + functionName: "delegates", + }); + }); +}); diff --git a/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.ts b/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.ts new file mode 100644 index 000000000..45ae7ba28 --- /dev/null +++ b/apps/dashboard/features/holders-and-delegates/delegate/utils/getDelegationReadContractConfig.ts @@ -0,0 +1,53 @@ +import type { Address } from "viem"; + +import daoConfigByDaoId from "@/shared/dao-config"; +import type { DaoOverviewConfig } from "@/shared/dao-config/types"; +import { DaoIdEnum } from "@/shared/types/daos"; + +type DelegationReadContracts = DaoOverviewConfig["contracts"]; + +export const DelegationReadAbi = [ + { + inputs: [{ internalType: "address", name: "account", type: "address" }], + name: "delegates", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "", type: "address" }], + name: "delegatedTo", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, +] as const; + +export type DelegationReadFunctionName = "delegates" | "delegatedTo"; + +interface DelegationReadContractConfig { + address?: Address; + functionName: DelegationReadFunctionName; +} + +const getTokenAddress = ( + token: DelegationReadContracts["token"] | undefined, +): Address | undefined => (typeof token === "string" ? token : undefined); + +export const getDelegationReadContractConfig = ( + daoId: DaoIdEnum, + contracts: Partial = daoConfigByDaoId[daoId] + .daoOverview.contracts, +): DelegationReadContractConfig => { + if (daoId === DaoIdEnum.TORN) { + return { + address: contracts.governor, + functionName: "delegatedTo", + }; + } + + return { + address: getTokenAddress(contracts.token), + functionName: "delegates", + }; +}; diff --git a/apps/dashboard/shared/hooks/useDelegators.ts b/apps/dashboard/shared/hooks/useDelegators.ts index ed517296e..7a1adda72 100644 --- a/apps/dashboard/shared/hooks/useDelegators.ts +++ b/apps/dashboard/shared/hooks/useDelegators.ts @@ -15,6 +15,7 @@ interface UseVotingPowerParams { orderBy?: DelegatorsQueryParamsOrderByEnumKey; orderDirection?: OrderDirection; limit?: number; + enabled?: boolean; } export const useDelegators = ({ @@ -23,6 +24,7 @@ export const useDelegators = ({ orderBy = "amount", orderDirection = "desc", limit = 15, + enabled = true, }: UseVotingPowerParams) => { const { data, @@ -40,7 +42,7 @@ export const useDelegators = ({ orderDirection, limit, }, - { query: { getNextPageParam } }, + { query: { enabled, getNextPageParam } }, ); const delegators = useMemo(() => { From e2202b43387d4f563ec256cdcc00169162641554 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 11:12:32 -0300 Subject: [PATCH 36/44] feat(dashboard): torn vote on proposal with delegated tokens --- .../components/modals/VotingModal.tsx | 37 ++++++- .../governance/utils/voteOnProposal.test.ts | 98 +++++++++++++++++++ .../governance/utils/voteOnProposal.ts | 21 ++-- 3 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/features/governance/utils/voteOnProposal.test.ts diff --git a/apps/dashboard/features/governance/components/modals/VotingModal.tsx b/apps/dashboard/features/governance/components/modals/VotingModal.tsx index 872078234..38057c393 100644 --- a/apps/dashboard/features/governance/components/modals/VotingModal.tsx +++ b/apps/dashboard/features/governance/components/modals/VotingModal.tsx @@ -1,8 +1,8 @@ "use client"; import { Check, User2Icon, X } from "lucide-react"; -import { useEffect, useState } from "react"; -import type { Account } from "viem"; +import { useEffect, useMemo, useState } from "react"; +import type { Account, Address } from "viem"; import { formatUnits } from "viem"; import { useAccount, useWalletClient } from "wagmi"; @@ -22,6 +22,7 @@ import { useGaslessEligibility, useRelayerConfig, } from "@/shared/hooks/useGaslessRelayer"; +import { useDelegators } from "@/shared/hooks/useDelegators"; import { DaoIdEnum } from "@/shared/types/daos"; import { formatNumberUserReadable } from "@/shared/utils/formatNumberUserReadable"; @@ -52,8 +53,9 @@ export const VotingModal = ({ const { isMobile } = useScreenSize(); - // Tornado Cash's governor uses castVote(uint256,bool) — binary only, no abstain. - const supportsAbstain = daoId !== DaoIdEnum.TORN; + // Tornado Cash's delegated vote path is binary only, with no abstain. + const isTorn = daoId === DaoIdEnum.TORN; + const supportsAbstain = !isTorn; // Parse user's voting power to BigInt for calculations const userVotingPowerBigInt = BigInt(rawVotingPower || "0"); @@ -108,6 +110,31 @@ export const VotingModal = ({ const { address, chain } = useAccount(); const { data: walletClient } = useWalletClient(); + const { delegators: tornDelegators, loading: isLoadingTornDelegators } = + useDelegators({ + daoId, + address: address ?? "", + orderBy: "amount", + orderDirection: "desc", + limit: 1000, + enabled: isOpen && isTorn && !!address, + }); + + const tornDelegatedVoteAddresses = useMemo(() => { + if (!isTorn || !address) return undefined; + + const seen = new Set(); + const addresses = tornDelegators + .map((delegator) => delegator.delegatorAddress as Address) + .filter((delegatorAddress) => { + const normalized = delegatorAddress.toLowerCase(); + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); + + return addresses.length > 0 ? addresses : [address]; + }, [address, isTorn, tornDelegators]); const { minVotingPower } = useRelayerConfig(daoId); const { isEligible: isGaslessEligible, remaining: voteRemaining } = @@ -168,6 +195,7 @@ export const VotingModal = ({ comment, minVotingPower, isGaslessEligible, + tornDelegatedVoteAddresses, ); setIsLoading(false); if (hash) { @@ -181,6 +209,7 @@ export const VotingModal = ({ !vote || !walletClient || isLoading || + (isTorn && isLoadingTornDelegators) || !rawVotingPower || rawVotingPower === "0"; diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.test.ts b/apps/dashboard/features/governance/utils/voteOnProposal.test.ts new file mode 100644 index 000000000..0f62c2b69 --- /dev/null +++ b/apps/dashboard/features/governance/utils/voteOnProposal.test.ts @@ -0,0 +1,98 @@ +import type { Account, Address, Chain, WalletClient } from "viem"; + +import { voteOnProposal } from "@/features/governance/utils/voteOnProposal"; +import { DaoIdEnum } from "@/shared/types/daos"; + +jest.mock("@anticapture/client", () => ({ + relayVote: jest.fn(), +})); + +jest.mock("@/features/governance/utils/showCustomToast", () => ({ + showCustomToast: jest.fn(), +})); + +const governor = "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce"; +const accountAddress = "0x1111111111111111111111111111111111111111"; +const transactionHash = "0x2222222222222222222222222222222222222222"; +const delegatorAddresses: Address[] = [ + "0x3333333333333333333333333333333333333333", + "0x4444444444444444444444444444444444444444", +]; + +describe("voteOnProposal", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("uses Tornado Cash castDelegatedVote for binary votes", async () => { + const account = { address: accountAddress } as unknown as Account; + const request = { to: governor }; + const simulateContract = jest.fn().mockResolvedValue({ request }); + const writeContract = jest.fn().mockResolvedValue(transactionHash); + const waitForTransactionReceipt = jest + .fn() + .mockResolvedValue({ transactionHash }); + const walletClient = { + extend: jest.fn(() => ({ + simulateContract, + writeContract, + waitForTransactionReceipt, + })), + } as unknown as WalletClient; + const setTransactionhash = jest.fn(); + + const receipt = await voteOnProposal( + "for", + "42", + account, + {} as Chain, + DaoIdEnum.TORN, + walletClient, + setTransactionhash, + undefined, + undefined, + false, + delegatorAddresses, + ); + + expect(receipt).toEqual({ transactionHash }); + expect(simulateContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: governor, + functionName: "castDelegatedVote", + args: [delegatorAddresses, 42n, true], + account, + }), + ); + expect(writeContract).toHaveBeenCalledWith(request); + expect(setTransactionhash).toHaveBeenNthCalledWith(1, transactionHash); + expect(setTransactionhash).toHaveBeenNthCalledWith(2, ""); + }); + + it("rejects Tornado Cash abstain votes before sending a transaction", async () => { + const account = { address: accountAddress } as unknown as Account; + const simulateContract = jest.fn(); + const walletClient = { + extend: jest.fn(() => ({ + simulateContract, + })), + } as unknown as WalletClient; + + const receipt = await voteOnProposal( + "abstain", + "42", + account, + {} as Chain, + DaoIdEnum.TORN, + walletClient, + jest.fn(), + ); + + expect(receipt).toBeNull(); + expect(simulateContract).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.ts b/apps/dashboard/features/governance/utils/voteOnProposal.ts index 74a06790e..b7c0940b6 100644 --- a/apps/dashboard/features/governance/utils/voteOnProposal.ts +++ b/apps/dashboard/features/governance/utils/voteOnProposal.ts @@ -1,6 +1,7 @@ import { parseSignature, publicActions } from "viem"; import type { Account, + Address, Chain, PublicActions, WalletActions, @@ -56,6 +57,7 @@ type VoteParams = { voteNumber: number; account: Account; comment?: string; + delegatedVoteAddresses?: Address[]; }; type VoteHandler = ( @@ -86,10 +88,11 @@ const azoriusVoteHandler = const TornGovernorVoteAbi = [ { inputs: [ + { internalType: "address[]", name: "from", type: "address[]" }, { internalType: "uint256", name: "proposalId", type: "uint256" }, { internalType: "bool", name: "support", type: "bool" }, ], - name: "castVote", + name: "castDelegatedVote", outputs: [], stateMutability: "nonpayable", type: "function", @@ -97,9 +100,9 @@ const TornGovernorVoteAbi = [ ] as const; /** - * Tornado Cash (custom stake-to-vote governor): castVote(uint256, bool) on the - * governor. Binary voting — for=true / against=false, no abstain, no reason. - * Voting power = lockedBalance, so the caller must have locked TORN. + * Tornado Cash (custom stake-to-vote governor): castDelegatedVote(address[], + * uint256, bool) on the governor. Binary voting — for=true / against=false, no + * abstain, no reason. */ const tornVoteHandler = (daoId: DaoIdEnum): VoteHandler => @@ -108,12 +111,16 @@ const tornVoteHandler = if (!address) throw new Error("DAO governance address not found"); if (params.voteNumber === 2) throw new Error("Tornado Cash does not support abstain votes"); + const fromAddresses = + params.delegatedVoteAddresses && params.delegatedVoteAddresses.length > 0 + ? params.delegatedVoteAddresses + : [params.account.address]; const { request } = await client.simulateContract({ abi: TornGovernorVoteAbi, address, - functionName: "castVote", - args: [BigInt(params.proposalId), params.voteNumber === 1], + functionName: "castDelegatedVote", + args: [fromAddresses, BigInt(params.proposalId), params.voteNumber === 1], account: params.account, }); return client.writeContract(request); @@ -225,6 +232,7 @@ export const voteOnProposal = async ( comment?: string, minVotingPower: bigint | null = null, useGasless: boolean = false, + delegatedVoteAddresses?: Address[], ) => { const client = walletClient.extend(publicActions); const voteNumber = vote === "for" ? 1 : vote === "against" ? 0 : 2; @@ -237,6 +245,7 @@ export const voteOnProposal = async ( voteNumber, account, comment: trimmedComment, + delegatedVoteAddresses, }); setTransactionhash(hash); From 1877182d301591c7f02371d14eba91139aa6b3f6 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 14:36:17 -0300 Subject: [PATCH 37/44] feat(dashboard): enable activity feed for torn --- apps/api/src/lib/eventRelevance.ts | 18 ++++++++++--- .../[daoId]/(main)/activity-feed/page.test.ts | 25 +++++++++++++++++++ .../app/[daoId]/(main)/activity-feed/page.tsx | 25 +++++++------------ .../whitelabel/[daoId]/activity-feed/page.tsx | 15 +++-------- .../features/feed/ActivityFeedSection.tsx | 8 +----- .../features/feed/hooks/useActivityFeed.ts | 4 +-- apps/dashboard/shared/dao-config/torn.ts | 1 + apps/indexer/src/indexer/torn/governor.ts | 12 ++++----- 8 files changed, 61 insertions(+), 47 deletions(-) create mode 100644 apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts diff --git a/apps/api/src/lib/eventRelevance.ts b/apps/api/src/lib/eventRelevance.ts index 9aa8c7010..dd83c6bc7 100644 --- a/apps/api/src/lib/eventRelevance.ts +++ b/apps/api/src/lib/eventRelevance.ts @@ -233,9 +233,21 @@ const DAO_RELEVANCE_THRESHOLDS: Record = { [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, }, [DaoIdEnum.TORN]: { - [FeedEventType.TRANSFER]: EMPTY_THRESHOLDS, - [FeedEventType.DELEGATION]: EMPTY_THRESHOLDS, - [FeedEventType.VOTE]: EMPTY_THRESHOLDS, + [FeedEventType.TRANSFER]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), + [FeedEventType.DELEGATION]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), + [FeedEventType.VOTE]: thresholds( + parseEther("1000"), + parseEther("10000"), + parseEther("100000"), + ), [FeedEventType.PROPOSAL]: EMPTY_THRESHOLDS, [FeedEventType.PROPOSAL_EXTENDED]: EMPTY_THRESHOLDS, }, diff --git a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts new file mode 100644 index 000000000..3e48afd2e --- /dev/null +++ b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.test.ts @@ -0,0 +1,25 @@ +import ActivityFeedPage from "./page"; +import { redirect } from "next/navigation"; + +jest.mock("next/navigation", () => ({ + redirect: jest.fn(), +})); + +jest.mock("@/features/feed", () => ({ + ActivityFeedSection: () => null, +})); + +describe("ActivityFeedPage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders Tornado activity feed when enabled in DAO config", async () => { + const result = await ActivityFeedPage({ + params: Promise.resolve({ daoId: "torn" }), + }); + + expect(redirect).not.toHaveBeenCalled(); + expect(result.props.feedDaoId).toBe("torn"); + }); +}); diff --git a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx index 4dde4a986..0d209cbf6 100644 --- a/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/activity-feed/page.tsx @@ -1,22 +1,9 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { - feedEventsPathParamsDaoEnum, - type FeedEventsPathParams, -} from "@anticapture/client"; import { ActivityFeedSection } from "@/features/feed"; - -type Props = { - params: Promise<{ daoId: string }>; -}; - -const supportedDaos: FeedEventsPathParams["dao"][] = Object.values( - feedEventsPathParamsDaoEnum, -); - -const isSupportedDao = (value: string): value is FeedEventsPathParams["dao"] => - supportedDaos.includes(value as FeedEventsPathParams["dao"]); +import daoConfigByDaoId from "@/shared/dao-config"; +import { toDaoIdEnum } from "@/shared/types/daos"; export async function generateMetadata(props: Props): Promise { const params = await props.params; @@ -49,11 +36,17 @@ export default async function ActivityFeedPage({ params: Promise<{ daoId: string }>; }) { const { daoId } = await params; + const daoIdEnum = toDaoIdEnum(daoId); const feedDaoId = daoId.toLowerCase(); + const daoConfig = daoIdEnum ? daoConfigByDaoId[daoIdEnum] : undefined; - if (!isSupportedDao(feedDaoId)) { + if (!daoConfig?.activityFeed) { redirect(`/${daoId}`); } return ; } + +type Props = { + params: Promise<{ daoId: string }>; +}; diff --git a/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx b/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx index 67e7b435e..b01f646cd 100644 --- a/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx +++ b/apps/dashboard/app/whitelabel/[daoId]/activity-feed/page.tsx @@ -1,11 +1,6 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; -import { - feedEventsPathParamsDaoEnum, - type FeedEventsPathParams, -} from "@anticapture/client"; - import { ActivityFeedSection } from "@/features/feed"; import daoConfigByDaoId from "@/shared/dao-config"; import { toDaoIdEnum } from "@/shared/types/daos"; @@ -14,12 +9,6 @@ type Props = { params: Promise<{ daoId: string }>; }; -const supportedDaos: string[] = Object.values(feedEventsPathParamsDaoEnum); - -function isSupportedDao(value: string): value is FeedEventsPathParams["dao"] { - return supportedDaos.includes(value); -} - export async function generateMetadata({ params }: Props): Promise { const { daoId } = await params; const daoIdEnum = toDaoIdEnum(daoId); @@ -38,9 +27,11 @@ export async function generateMetadata({ params }: Props): Promise { export default async function WhitelabelActivityFeedPage({ params }: Props) { const { daoId } = await params; + const daoIdEnum = toDaoIdEnum(daoId); const feedDaoId = daoId.toLowerCase(); + const daoConfig = daoIdEnum ? daoConfigByDaoId[daoIdEnum] : undefined; - if (!isSupportedDao(feedDaoId)) { + if (!daoConfig?.activityFeed) { redirect(`/whitelabel/${daoId}`); } diff --git a/apps/dashboard/features/feed/ActivityFeedSection.tsx b/apps/dashboard/features/feed/ActivityFeedSection.tsx index 693f37913..235da7ca0 100644 --- a/apps/dashboard/features/feed/ActivityFeedSection.tsx +++ b/apps/dashboard/features/feed/ActivityFeedSection.tsx @@ -3,8 +3,6 @@ import { Activity, Filter, Loader2, Newspaper } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; -import type { FeedEventsPathParams } from "@anticapture/client"; - import { ActivityFeedFiltersDrawer } from "@/features/feed/components/ActivityFeedFilters"; import { FeedEventItem } from "@/features/feed/components/FeedEventItem"; import { FeedEventSkeleton } from "@/features/feed/components/FeedEventSkeleton"; @@ -24,11 +22,7 @@ import { PAGES_CONSTANTS } from "@/shared/constants/pages-constants"; import { useDaoId } from "@/shared/providers/DaoIdProvider"; import { cn } from "@/shared/utils/cn"; -export const ActivityFeedSection = ({ - feedDaoId, -}: { - feedDaoId: FeedEventsPathParams["dao"]; -}) => { +export const ActivityFeedSection = ({ feedDaoId }: { feedDaoId: string }) => { const daoId = useDaoId(); const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false); const [drawerState, setDrawerState] = useState<{ diff --git a/apps/dashboard/features/feed/hooks/useActivityFeed.ts b/apps/dashboard/features/feed/hooks/useActivityFeed.ts index 8a63c8a9b..1179597c7 100644 --- a/apps/dashboard/features/feed/hooks/useActivityFeed.ts +++ b/apps/dashboard/features/feed/hooks/useActivityFeed.ts @@ -10,7 +10,7 @@ export const useActivityFeed = ({ daoId, filters, }: { - daoId: FeedEventsPathParams["dao"]; + daoId: string; filters: FeedEventsQueryParams; }) => { const { @@ -23,7 +23,7 @@ export const useActivityFeed = ({ refetch, isFetching, } = useFeedEventsInfinite( - daoId, + daoId as FeedEventsPathParams["dao"], { limit: filters.limit, orderBy: filters.orderBy, diff --git a/apps/dashboard/shared/dao-config/torn.ts b/apps/dashboard/shared/dao-config/torn.ts index f97be29d6..114ff0698 100644 --- a/apps/dashboard/shared/dao-config/torn.ts +++ b/apps/dashboard/shared/dao-config/torn.ts @@ -291,4 +291,5 @@ export const TORN: DaoConfiguration = { tokenDistribution: true, dataTables: true, governancePage: true, + activityFeed: true, }; diff --git a/apps/indexer/src/indexer/torn/governor.ts b/apps/indexer/src/indexer/torn/governor.ts index 16e7dae5f..a0df991e9 100644 --- a/apps/indexer/src/indexer/torn/governor.ts +++ b/apps/indexer/src/indexer/torn/governor.ts @@ -130,7 +130,7 @@ export function TORNGovernorIndexer(blockTime: number) { endTimestamp: endTime, }); - const { votingPower: proposerVotingPower } = await context.db + await context.db .insert(accountPower) .values({ accountId: getAddress(proposer), @@ -141,17 +141,15 @@ export function TORNGovernorIndexer(blockTime: number) { proposalsCount: current.proposalsCount + 1, })); + // Set the proposal_id column (not ad-hoc metadata) — the API feed + // enrichment rebuilds title/proposer/link from the proposal row keyed + // by this column and ignores the metadata JSON for PROPOSAL events. 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, - }, + proposalId: proposalIdStr, }); }); From e8cd7aced244f952b7dcfe4c181007ef1052f9b2 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 14:45:58 -0300 Subject: [PATCH 38/44] fix(dashboard): vote w/o delegator --- .../features/governance/utils/voteOnProposal.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/features/governance/utils/voteOnProposal.ts b/apps/dashboard/features/governance/utils/voteOnProposal.ts index b7c0940b6..44248fd67 100644 --- a/apps/dashboard/features/governance/utils/voteOnProposal.ts +++ b/apps/dashboard/features/governance/utils/voteOnProposal.ts @@ -111,10 +111,11 @@ const tornVoteHandler = if (!address) throw new Error("DAO governance address not found"); if (params.voteNumber === 2) throw new Error("Tornado Cash does not support abstain votes"); - const fromAddresses = - params.delegatedVoteAddresses && params.delegatedVoteAddresses.length > 0 - ? params.delegatedVoteAddresses - : [params.account.address]; + // `from` lists ONLY the accounts that delegated to the voter — the governor + // requires delegatedTo[from[i]] == msg.sender and casts the voter's own + // locked balance separately. Including the voter (or any self-vote) reverts, + // since self-delegation is forbidden. No delegators => empty list. + const fromAddresses = params.delegatedVoteAddresses ?? []; const { request } = await client.simulateContract({ abi: TornGovernorVoteAbi, From 732e60b4292813f09c894ec5714de705f46e6444 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 15:03:40 -0300 Subject: [PATCH 39/44] fix(indexer): use locked balance for TORN delegated value when zero --- apps/indexer/src/eventHandlers/delegation.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/indexer/src/eventHandlers/delegation.ts b/apps/indexer/src/eventHandlers/delegation.ts index d3a34f89c..cecdd0d3d 100644 --- a/apps/indexer/src/eventHandlers/delegation.ts +++ b/apps/indexer/src/eventHandlers/delegation.ts @@ -73,12 +73,13 @@ export const delegateChanged = async ( // Ensure all required accounts exist in parallel await ensureAccountsExist(context, [delegator, delegate]); - const delegatorBalance = _delegatorBalance - ? { balance: _delegatorBalance } - : await context.db.find(accountBalance, { - accountId: normalizedDelegator, - tokenId: getAddress(tokenId), - }); + const delegatorBalance = + _delegatorBalance !== undefined + ? { balance: _delegatorBalance } + : await context.db.find(accountBalance, { + accountId: normalizedDelegator, + tokenId: getAddress(tokenId), + }); // Pre-compute address lists for flag determination (normalized to checksum) const { cex, dex, lending, burning } = addressSets ?? { From 937c42f32fea5bdf8bcfa7337617c50784e47b1e Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 15:11:57 -0300 Subject: [PATCH 40/44] fix(dashboard): render proposals relying on tx hash --- apps/api/src/repositories/feed/index.ts | 59 ++++++++++++++++++------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/apps/api/src/repositories/feed/index.ts b/apps/api/src/repositories/feed/index.ts index ac4e2e351..660222441 100644 --- a/apps/api/src/repositories/feed/index.ts +++ b/apps/api/src/repositories/feed/index.ts @@ -104,25 +104,35 @@ export class FeedRepository { const voteKeys = rows .filter((r) => r.type === FeedEventType.VOTE) .map((r) => ({ txHash: r.txHash, logIndex: r.logIndex })); - const proposalIds = Array.from( + // A PROPOSAL creation event and its proposal row are written from the same + // log, so match them by txHash (one ProposalCreated per tx). This avoids + // relying on feed_event.proposal_id, which some governors (e.g. TORN) leave + // null on the creation event. PROPOSAL_EXTENDED lives in a different tx, so + // it still keys by proposal_id. + const proposalTxHashes = Array.from( new Set( rows - .filter( - (r) => - r.type === FeedEventType.PROPOSAL || - r.type === FeedEventType.PROPOSAL_EXTENDED, - ) + .filter((r) => r.type === FeedEventType.PROPOSAL) + .map((r) => r.txHash), + ), + ); + const extendedProposalIds = Array.from( + new Set( + rows + .filter((r) => r.type === FeedEventType.PROPOSAL_EXTENDED) .map((r) => r.proposalId) .filter((id): id is string => id != null), ), ); - const [delegations, transfers, votes, proposals] = await Promise.all([ - this.fetchDelegations(delegationKeys), - this.fetchTransfers(transferKeys), - this.fetchVotes(voteKeys), - this.fetchProposals(proposalIds), - ]); + const [delegations, transfers, votes, createdProposals, extendedProposals] = + await Promise.all([ + this.fetchDelegations(delegationKeys), + this.fetchTransfers(transferKeys), + this.fetchVotes(voteKeys), + this.fetchProposalsByTxHash(proposalTxHashes), + this.fetchProposals(extendedProposalIds), + ]); const delegationByKey = new Map( delegations.map((d) => [`${d.transactionHash}:${d.logIndex}`, d]), @@ -133,7 +143,10 @@ export class FeedRepository { const voteByKey = new Map( votes.map((v) => [`${v.txHash}:${v.logIndex}`, v]), ); - const proposalById = new Map(proposals.map((p) => [p.id, p])); + const proposalByTxHash = new Map( + createdProposals.map((p) => [p.txHash, p]), + ); + const proposalById = new Map(extendedProposals.map((p) => [p.id, p])); return rows.map((row) => ({ ...row, @@ -141,6 +154,7 @@ export class FeedRepository { delegationByKey, transferByKey, voteByKey, + proposalByTxHash, proposalById, }), })); @@ -152,6 +166,7 @@ export class FeedRepository { delegationByKey: Map; transferByKey: Map; voteByKey: Map; + proposalByTxHash: Map; proposalById: Map; }, ): FeedMetadata | null { @@ -195,8 +210,7 @@ export class FeedRepository { return meta; } case FeedEventType.PROPOSAL: { - if (!row.proposalId) return null; - const p = lookups.proposalById.get(row.proposalId); + const p = lookups.proposalByTxHash.get(row.txHash); if (!p) return null; const meta: ProposalMeta = { kind: FeedEventType.PROPOSAL, @@ -286,9 +300,21 @@ export class FeedRepository { private async fetchProposals(proposalIds: string[]): Promise { if (proposalIds.length === 0) return []; + return this.selectProposals(inArray(proposalsOnchain.id, proposalIds)); + } + + private async fetchProposalsByTxHash( + txHashes: string[], + ): Promise { + if (txHashes.length === 0) return []; + return this.selectProposals(inArray(proposalsOnchain.txHash, txHashes)); + } + + private selectProposals(where: SQL): Promise { return this.db .select({ id: proposalsOnchain.id, + txHash: proposalsOnchain.txHash, proposerAccountId: proposalsOnchain.proposerAccountId, title: proposalsOnchain.title, endBlock: proposalsOnchain.endBlock, @@ -311,7 +337,7 @@ export class FeedRepository { )`, }) .from(proposalsOnchain) - .where(inArray(proposalsOnchain.id, proposalIds)); + .where(where); } private buildRelevanceFilter( @@ -366,6 +392,7 @@ type VoteRow = { type ProposalRow = { id: string; + txHash: string; proposerAccountId: string; title: string; endBlock: number; From 873bb4514e144aaece91246c86ba61e0e7f54c1f Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 17:33:05 -0300 Subject: [PATCH 41/44] fix(api): rearrange torn transfer from and to --- .changeset/torn-lock-transfer-direction.md | 5 +++ .../api/src/repositories/voting-power/torn.ts | 36 +++++++++++++------ 2 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 .changeset/torn-lock-transfer-direction.md diff --git a/.changeset/torn-lock-transfer-direction.md b/.changeset/torn-lock-transfer-direction.md new file mode 100644 index 000000000..328df6356 --- /dev/null +++ b/.changeset/torn-lock-transfer-direction.md @@ -0,0 +1,5 @@ +--- +"@anticapture/api": patch +--- + +Normalize TORN lock/unlock transfer direction in voting-power history so the locker (not the custody contract) is shown as the delegator. diff --git a/apps/api/src/repositories/voting-power/torn.ts b/apps/api/src/repositories/voting-power/torn.ts index 894c25655..82262fb29 100644 --- a/apps/api/src/repositories/voting-power/torn.ts +++ b/apps/api/src/repositories/voting-power/torn.ts @@ -110,18 +110,34 @@ export class TORNVotingPowerRepository { .limit(limit) .offset(skip); - return result.map((row) => ({ - ...row.voting_power_history, - delegations: - row.transfers && - row.transfers?.logIndex > (row.delegations?.logIndex || 0) - ? null - : row.delegations, - transfers: + return result.map((row) => { + const transfers = row.delegations && row.delegations?.logIndex > (row.transfers?.logIndex || 0) ? null - : row.transfers, - })); + : row.transfers; + + return { + ...row.voting_power_history, + delegations: + row.transfers && + row.transfers?.logIndex > (row.delegations?.logIndex || 0) + ? null + : row.delegations, + // TORN's lock/unlock Transfer moves tokens OPPOSITE to the voting-power + // change: a lock (VP gain) sends TORN wallet -> custody, an unlock (VP + // loss) sends custody -> wallet. The dashboard derives the delegator as + // `isGain ? transfer.to : transfer.from`, which would surface the + // custody contract instead of the locker. Swap from/to so the locker + // who caused the change is shown. + transfers: transfers + ? { + ...transfers, + fromAccountId: transfers.toAccountId, + toAccountId: transfers.fromAccountId, + } + : null, + }; + }); } } From bb0a8f0aaec099e6741223aa9fecbff4c5861c7a Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 17:45:28 -0300 Subject: [PATCH 42/44] refactor(indexer): move torn governor from non-circulating to treasury --- apps/api/src/lib/constants.ts | 7 +++---- apps/indexer/src/lib/constants.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/api/src/lib/constants.ts b/apps/api/src/lib/constants.ts index c3086277c..3aa56416e 100644 --- a/apps/api/src/lib/constants.ts +++ b/apps/api/src/lib/constants.ts @@ -403,10 +403,9 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, - // Governor custody (0x5efda...A1Ce) holds locked stakes — tracked as - // non-circulating, not treasury. Kept out of treasury to stay in sync with - // the indexer (where listing it in both buckets double-subtracts circulating). - [DaoIdEnum.TORN]: {}, + [DaoIdEnum.TORN]: { + governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + }, }; // Mirrors the indexer's NonCirculatingAddresses. Locked/vesting supply held by diff --git a/apps/indexer/src/lib/constants.ts b/apps/indexer/src/lib/constants.ts index af55aa6a4..339ff548c 100644 --- a/apps/indexer/src/lib/constants.ts +++ b/apps/indexer/src/lib/constants.ts @@ -417,9 +417,9 @@ export const TreasuryAddresses: Record> = { "0x639f35C5E212D61Fe14Bd5CD8b66aAe4df11a50c", InstaTimelock: "0xC7Cb1dE2721BFC0E0DA1b9D526bCdC54eF1C0eFC", }, - // Governor custody (0x5efda...A1Ce) holds locked stakes, already subtracted - // via NonCirculatingAddresses[TORN].governance — not a treasury. - [DaoIdEnum.TORN]: {}, + [DaoIdEnum.TORN]: { + governor: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", + }, }; export const CEXAddresses: Record> = { @@ -713,7 +713,12 @@ export const DEXAddresses: Record> = { [DaoIdEnum.FLUID]: { "Uniswap V3 INST/WETH": "0xc1cd3D0913f4633b43FcdDBCd7342bC9b71C676f", }, - [DaoIdEnum.TORN]: {}, + [DaoIdEnum.TORN]: { + "Uniswap v2 | TORN/WETH": "0x0c722a487876989af8a05fffb6e32e45cc23fb3a", + "Uniswap v3 | TORN/WETH 1%": "0x97a5a0b2d7ed3accb7fd6404a1f5ca29320905af", + "Uniswap v3 | TORN/WETH 0,3%": "0x753a90ae2fa03d31487141bf54bd853b27f7bcf5", + "Sushiswap | TORN/WETH": "0xb270176ba6075196df88b855c3ec7776871fdb33", + }, }; export const LendingAddresses: Record> = { @@ -898,7 +903,6 @@ export const NonCirculatingAddresses: Record< [DaoIdEnum.LIL_NOUNS]: {}, [DaoIdEnum.SHU]: {}, [DaoIdEnum.TORN]: { - governance: "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce", vault: "0x2F50508a8a3D323B91336FA3eA6ae50E55f32185", }, }; From 6c4fd5b451979c7482f52f6f75eed12646659920 Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 17:52:47 -0300 Subject: [PATCH 43/44] chore(dashboard): renamed delegated to blocked on torn tokens --- .../attack-profitability/AttackProfitabilitySection.tsx | 3 ++- .../attack-profitability/components/AttackCostBarChart.tsx | 3 ++- .../components/AttackProfitabilityToggleHeader.tsx | 4 +++- .../components/MultilineChartAttackProfitability.tsx | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx b/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx index c35f128d9..88c62c54d 100644 --- a/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx +++ b/apps/dashboard/features/attack-profitability/AttackProfitabilitySection.tsx @@ -27,9 +27,10 @@ export const AttackProfitabilitySection = ({ attackProfitability: AttackProfitabilityConfig; }) => { const defaultDays = TimeInterval.ONE_YEAR; + const defaultCostMetric = daoId === "TORN" ? "Locked" : "Delegated"; const [days, setDays] = useState(defaultDays); const [treasuryMetric, setTreasuryMetric] = useState(`Non-${daoId}`); - const [costMetric, setCostMetric] = useState("Delegated"); + const [costMetric, setCostMetric] = useState(defaultCostMetric); const [dropdownValue, setDropdownValue] = useState
diff --git a/apps/dashboard/features/attack-profitability/components/MultilineChartAttackProfitability.tsx b/apps/dashboard/features/attack-profitability/components/MultilineChartAttackProfitability.tsx index d034c4ac7..ead815e65 100644 --- a/apps/dashboard/features/attack-profitability/components/MultilineChartAttackProfitability.tsx +++ b/apps/dashboard/features/attack-profitability/components/MultilineChartAttackProfitability.tsx @@ -105,7 +105,10 @@ export const MultilineChartAttackProfitability = ({ }, all: { label: "All", color: "#4ade80" }, quorum: { label: "Quorum", color: "#f87171" }, - delegated: { label: "Delegated", color: "#f87171" }, + delegated: { + label: daoEnum === "TORN" ? "Locked" : "Delegated", + color: "#f87171", + }, }), [daoEnum], ) satisfies ChartConfig; From 2eed136e8e6414baee3f13d0c71f13d7153e424a Mon Sep 17 00:00:00 2001 From: lucas picollo Date: Wed, 1 Jul 2026 18:12:50 -0300 Subject: [PATCH 44/44] fix(indexer): exclude torn governance locks from treasury metric Genuine lock/unlock transfers and governor<->vault internal moves no longer count as treasury flows; governor restored as lock custody so pre-vault locks are tracked, attributed to non-circulating supply. Co-Authored-By: Claude Fable 5 --- apps/indexer/src/indexer/torn/erc20.ts | 138 ++++++++++++++----------- 1 file changed, 79 insertions(+), 59 deletions(-) diff --git a/apps/indexer/src/indexer/torn/erc20.ts b/apps/indexer/src/indexer/torn/erc20.ts index 9515c4914..c6a3b5cfe 100644 --- a/apps/indexer/src/indexer/torn/erc20.ts +++ b/apps/indexer/src/indexer/torn/erc20.ts @@ -15,6 +15,7 @@ import { MetricTypesEnum, BurningAddresses, CEXAddresses, + CONTRACT_ADDRESSES, DEXAddresses, LendingAddresses, TreasuryAddresses, @@ -25,15 +26,21 @@ import { DaoIdEnum } from "@/lib/enums"; export function TORNTokenIndexer(address: Address, decimals: number) { const daoId = DaoIdEnum.TORN; - // Contracts that custody locked TORN. A balance held here is voting power - // owned by the locker, not the custody contract. TORN emits no - // DelegateVotesChanged, so per-account voting power is derived from - // lock/unlock Transfers in/out of these addresses. Add new lock contracts here. - const lockCustodyAddresses = new Set
( - Object.values(NonCirculatingAddresses[daoId]).map((addr) => + // Contracts that custody locked TORN: the governor pre-vault, the + // TornadoVault post-vault. A balance held here is voting power owned by the + // locker, not the custody contract. TORN emits no DelegateVotesChanged, so + // per-account voting power is derived from lock/unlock Transfers in/out of + // these addresses. Add new lock contracts here. The governor is a treasury + // address (not non-circulating), so it is added explicitly. + const governorAddress = getAddress( + CONTRACT_ADDRESSES[daoId].governor.address, + ); + const lockCustodyAddresses = new Set
([ + governorAddress, + ...Object.values(NonCirculatingAddresses[daoId]).map((addr) => getAddress(addr), ), - ); + ]); ponder.on("TORNToken:setup", async ({ context }) => { await context.db.insert(token).values({ @@ -115,31 +122,71 @@ export function TORNTokenIndexer(address: Address, decimals: number) { timestamp, ); - await updateSupplyMetric( - context, - "treasury", - treasuryAddressList, - MetricTypesEnum.TREASURY, - from, - to, - value, - daoId, - address, - timestamp, - ); + // Locks/unlocks: TORN moving in/out of governance custody — the governor + // pre-vault, the TornadoVault post-vault (GovernanceVaultUpgrade._ + // transferTokens). Only a genuine lock()/unlock() call moves voting power, + // and in that case the locker is always the transaction sender: lock pulls + // TORN from msg.sender, unlock sends it back to msg.sender. TORN also + // flows through the governor for treasury purposes — proposal executions + // funding a contract, batch grant payouts — where the custody counterparty + // is some arbitrary address that is NOT the tx sender. Counting those as + // locks/unlocks mints/burns phantom voting power (e.g. proposal #6 funding + // a staking pool booked a -120k "unlock" against a contract that never + // locked; treasury feeders got phantom locks). Governor<->vault internal + // moves (e.g. the v2 migration, ~2.6M TORN) have custody on both sides and + // net to zero, so they are skipped. + // ponytail: misses locks routed via a Safe/relayer (counterparty != tx + // sender) — both their lock and unlock are skipped so it stays consistent + // (undercount, never negative). Tighten with a Governance call-trace check + // if Safe-locked TORN needs to be captured. + const normalizedTo = getAddress(to); + const normalizedFrom = getAddress(from); + const toIsCustody = lockCustodyAddresses.has(normalizedTo); + const fromIsCustody = lockCustodyAddresses.has(normalizedFrom); + const custodyInternal = toIsCustody && fromIsCustody; + const locker = toIsCustody ? normalizedFrom : normalizedTo; + const isLockOrUnlock = + toIsCustody !== fromIsCustody && + locker === getAddress(event.transaction.from); + + // Locked TORN is user-owned voting power, not DAO funds: genuine + // locks/unlocks and the governor<->vault migration never touch TREASURY, + // even though the governor is the treasury address. + if (!isLockOrUnlock && !custodyInternal) { + 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, - ); + // Locked TORN is non-circulating in every era: the vault always is; the + // governor (a treasury address) counts only for genuine locks so + // pre-vault locks match post-vault ones. The migration moved + // already-counted locks between custodies, so it is skipped. + if (!custodyInternal) { + await updateSupplyMetric( + context, + "nonCirculatingSupply", + isLockOrUnlock + ? [...nonCirculatingAddressList, governorAddress] + : nonCirculatingAddressList, + MetricTypesEnum.NON_CIRCULATING_SUPPLY, + from, + to, + value, + daoId, + address, + timestamp, + ); + } await updateTotalSupply( context, @@ -155,36 +202,9 @@ export function TORNTokenIndexer(address: Address, decimals: number) { await updateCirculatingSupply(context, daoId, address, timestamp); - // Track locks/unlocks: TORN moving in/out of governance custody — the - // governor pre-v2, the TornadoVault post-v2 (GovernanceVaultUpgrade._ - // transferTokens). Both are lock sinks. Governor<->vault internal moves - // (e.g. the v2 migration) have custody on both sides and net to zero, so - // they are skipped. Governor-only accounting missed ~2.6M TORN in the Vault. - const normalizedTo = getAddress(to); - const normalizedFrom = getAddress(from); - const toIsCustody = lockCustodyAddresses.has(normalizedTo); - const fromIsCustody = lockCustodyAddresses.has(normalizedFrom); - - if (toIsCustody !== fromIsCustody) { - const locker = toIsCustody ? normalizedFrom : normalizedTo; + if (isLockOrUnlock) { const delta = toIsCustody ? value : -value; - // Only a genuine lock()/unlock() call moves voting power, and in that case - // the locker is always the transaction sender: lock pulls TORN from - // msg.sender, unlock sends it back to msg.sender. TORN also flows through - // the governor for treasury purposes — proposal executions funding a - // contract, batch grant payouts, the governor<->vault migration — where - // the custody counterparty is some arbitrary address that is NOT the tx - // sender. Counting those as locks/unlocks mints/burns phantom voting power - // (e.g. proposal #6 funding a staking pool booked a -120k "unlock" against - // a contract that never locked; treasury feeders got phantom locks). Skip - // any custody transfer whose counterparty isn't the tx sender. - // ponytail: misses locks routed via a Safe/relayer (counterparty != tx - // sender) — both their lock and unlock are skipped so it stays consistent - // (undercount, never negative). Tighten with a Governance call-trace check - // if Safe-locked TORN needs to be captured. - if (locker !== getAddress(event.transaction.from)) return; - // Aggregate locked (delegated) supply. await updateDelegatedSupply(context, daoId, address, delta, timestamp);