Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/torn-castvote-no-delegators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": patch
---

Fix Tornado Cash voting for accounts without delegators by using castVote instead of castDelegatedVote.
5 changes: 5 additions & 0 deletions .changeset/tornado-whitelabel-governance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@anticapture/dashboard": minor
---

Enable the Tornado Cash whitelabel with full governance (create, vote and execute proposals).
1 change: 1 addition & 0 deletions apps/dashboard/features/create-proposal/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const BODY_WARNING_THRESHOLD = 95_000;
export const canCreateProposalForDao = (daoId: DaoIdEnum | null | undefined) =>
daoId === DaoIdEnum.ENS ||
daoId === DaoIdEnum.SHU ||
daoId === DaoIdEnum.TORN ||
daoId === DaoIdEnum.COMP ||
daoId === DaoIdEnum.GITCOIN;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { Address, Hex } from "viem";

import {
TORN_EXECUTE_PROPOSAL_CALLDATA,
getProposalCreatedEventAbi,
isTornadoDao,
submitProposalRequest,
} from "@/features/create-proposal/utils/submitProposalRequest";
import { DaoIdEnum } from "@/shared/types/daos";

const governorAddress: Address = "0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce";
const proposalContract: Address = "0x1111111111111111111111111111111111111111";
const executeProposalCalldata = TORN_EXECUTE_PROPOSAL_CALLDATA as Hex;

type WriteContractFn = Parameters<typeof submitProposalRequest>[0];

const makeWriteContract = () => {
const mock = jest.fn();
const writeContract: WriteContractFn = mock;
return { writeContract, mock };
};

const baseParams = {
governorAddress,
title: "Title",
body: "Body",
discussionUrl: "",
chainId: 1,
};

describe("submitProposalRequest (Tornado Cash)", () => {
it("classifies only TORN as a Tornado DAO", () => {
expect(isTornadoDao(DaoIdEnum.TORN)).toBe(true);
expect(isTornadoDao(DaoIdEnum.ENS)).toBe(false);
expect(isTornadoDao(DaoIdEnum.SHU)).toBe(false);
});

it("proposes with the executeProposal() action's address as the proposal contract", () => {
const { writeContract, mock } = makeWriteContract();

submitProposalRequest(writeContract, {
...baseParams,
daoId: DaoIdEnum.TORN,
encoded: {
targets: [proposalContract],
values: [0n],
calldatas: [executeProposalCalldata],
},
});

expect(mock).toHaveBeenCalledTimes(1);
const call = mock.mock.calls[0][0];
expect(call.address).toBe(governorAddress);
expect(call.functionName).toBe("propose");
expect(call.args).toEqual([proposalContract, "# Title\n\nBody"]);
});

it("rejects proposals with more than one action", () => {
const { writeContract, mock } = makeWriteContract();

expect(() =>
submitProposalRequest(writeContract, {
...baseParams,
daoId: DaoIdEnum.TORN,
encoded: {
targets: [proposalContract, governorAddress],
values: [0n, 0n],
calldatas: [executeProposalCalldata, executeProposalCalldata],
},
}),
).toThrow(/exactly one custom action/);
expect(mock).not.toHaveBeenCalled();
});

it("rejects actions that are not an executeProposal() call", () => {
const { writeContract, mock } = makeWriteContract();

// An erc20 transfer (or any other calldata) would create a proposal whose
// delegatecalled execution does not match what the DAO reviewed.
expect(() =>
submitProposalRequest(writeContract, {
...baseParams,
daoId: DaoIdEnum.TORN,
encoded: {
targets: [proposalContract],
values: [0n],
calldatas: ["0xa9059cbb" as Hex],
},
}),
).toThrow(/executeProposal/);
expect(mock).not.toHaveBeenCalled();
});

it("rejects proposals that send ETH", () => {
const { writeContract, mock } = makeWriteContract();

expect(() =>
submitProposalRequest(writeContract, {
...baseParams,
daoId: DaoIdEnum.TORN,
encoded: {
targets: [proposalContract],
values: [1n],
calldatas: [executeProposalCalldata],
},
}),
).toThrow(/cannot send ETH/);
expect(mock).not.toHaveBeenCalled();
});

it("keeps the OZ 4-arg propose for non-Tornado DAOs", () => {
const { writeContract, mock } = makeWriteContract();

submitProposalRequest(writeContract, {
...baseParams,
daoId: DaoIdEnum.ENS,
encoded: {
targets: [proposalContract],
values: [0n],
calldatas: ["0x" as Hex],
},
});

const call = mock.mock.calls[0][0];
expect(call.functionName).toBe("propose");
expect(call.args).toEqual([
[proposalContract],
[0n],
["0x"],
"# Title\n\nBody",
]);
});

it("returns the Tornado ProposalCreated event ABI for TORN", () => {
const abi = getProposalCreatedEventAbi(DaoIdEnum.TORN);
const event = abi.find((entry) => entry.name === "ProposalCreated");
const inputNames = event?.inputs.map((input) => input.name);
// Same shape the indexer consumes: id and proposer indexed, then target,
// startTime, endTime and description.
expect(inputNames).toEqual([
"proposalId",
"proposer",
"target",
"startTime",
"endTime",
"description",
]);
expect(event?.inputs[0].indexed).toBe(true);
expect(event?.inputs[1].indexed).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ const azoriusProposalCreatedEventAbi = [
},
] as const satisfies Abi;

// Tornado Cash governance: proposals are pre-deployed contracts that the
// governance contract delegatecalls on execution, so propose takes a single
// target address and a description instead of action arrays.
const tornProposeAbi = [
{
type: "function",
name: "propose",
stateMutability: "nonpayable",
inputs: [
{ name: "target", type: "address" },
{ name: "description", type: "string" },
],
outputs: [{ name: "", type: "uint256" }],
},
] as const satisfies Abi;

/**
* Selector of executeProposal(), the function Tornado governance delegatecalls
* on the proposal contract when a passed proposal is executed. A TORN draft is
* only publishable when its single action is exactly this call, so what the
* DAO reviews matches what execution will do.
*/
export const TORN_EXECUTE_PROPOSAL_CALLDATA = "0x373058b8";

const tornProposalCreatedEventAbi = [
{
type: "event",
name: "ProposalCreated",
inputs: [
{ indexed: true, name: "proposalId", 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" },
],
},
] as const satisfies Abi;

// Gnosis Safe Enum.Operation.Call — the only operation the UI emits.
const SAFE_OPERATION_CALL = 0;

Expand All @@ -102,6 +141,9 @@ const AZORIUS_EMPTY_STRATEGY_DATA = "0x" as const;
/** DAOs whose proposals go through an Azorius module rather than an OZ Governor. */
export const isAzoriusDao = (daoId: DaoIdEnum) => daoId === DaoIdEnum.SHU;

/** DAOs on Tornado Cash's custom stake-to-vote governance. */
export const isTornadoDao = (daoId: DaoIdEnum) => daoId === DaoIdEnum.TORN;

export interface EncodedActions {
targets: Address[];
values: bigint[];
Expand Down Expand Up @@ -179,22 +221,48 @@ export const submitProposalRequest = (
return;
}

const description = encodeDescription(title, discussionUrl ?? "", body);

if (isTornadoDao(daoId)) {
// The single action's contract address is the pre-deployed proposal
// contract the governance will delegatecall on execution, which always
// calls the target's executeProposal(). Any other action (a transfer, a
// different function) would create a proposal whose execution silently
// does something else or reverts, so it is rejected before the wallet.
if (
encoded.targets.length !== 1 ||
encoded.calldatas[0]?.toLowerCase() !== TORN_EXECUTE_PROPOSAL_CALLDATA
) {
throw new Error(
"Tornado Cash proposals delegatecall a single pre-deployed proposal contract; add exactly one custom action calling executeProposal() on it.",
);
}
if ((encoded.values[0] ?? 0n) !== 0n) {
throw new Error("Tornado Cash proposals cannot send ETH.");
}

writeContract({
address: governorAddress,
abi: tornProposeAbi,
functionName: "propose",
args: [encoded.targets[0], description],
Comment thread
brunod-e marked this conversation as resolved.
chainId,
});
return;
}

writeContract({
address: governorAddress,
abi: ozProposeAbi,
functionName: "propose",
args: [
encoded.targets,
encoded.values,
encoded.calldatas,
encodeDescription(title, discussionUrl ?? "", body),
],
args: [encoded.targets, encoded.values, encoded.calldatas, description],
chainId,
});
};

/** The `ProposalCreated` event ABI matching the DAO's governance mechanism. */
export const getProposalCreatedEventAbi = (daoId: DaoIdEnum) =>
isAzoriusDao(daoId)
? azoriusProposalCreatedEventAbi
: ozProposalCreatedEventAbi;
export const getProposalCreatedEventAbi = (daoId: DaoIdEnum) => {
if (isAzoriusDao(daoId)) return azoriusProposalCreatedEventAbi;
if (isTornadoDao(daoId)) return tornProposalCreatedEventAbi;
return ozProposalCreatedEventAbi;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Check, User2Icon, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { Account, Address } from "viem";
import type { Address } from "viem";
import { formatUnits } from "viem";
import { useAccount, useWalletClient } from "wagmi";

Expand Down Expand Up @@ -113,15 +113,47 @@ 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 {
delegators: tornDelegators,
loading: isLoadingTornDelegators,
error: tornDelegatorsError,
hasNextPage: hasMoreTornDelegators,
fetchNextPage: fetchMoreTornDelegators,
fetchingMore: isFetchingMoreTornDelegators,
} = useDelegators({
daoId,
address: address ?? "",
orderBy: "amount",
orderDirection: "desc",
limit: 1000,
Comment thread
brunod-e marked this conversation as resolved.
enabled: isOpen && isTorn && !!address,
});

// castDelegatedVote must carry every delegator, so drain the paginated
// query; a truncated list would silently omit delegated voting power.
useEffect(() => {
if (!isOpen || !isTorn || !address) return;
if (
tornDelegatorsError ||
!hasMoreTornDelegators ||
isFetchingMoreTornDelegators
)
return;
fetchMoreTornDelegators();
}, [
isOpen,
isTorn,
address,
tornDelegatorsError,
hasMoreTornDelegators,
isFetchingMoreTornDelegators,
fetchMoreTornDelegators,
]);

const isTornDelegatorListIncomplete =
isLoadingTornDelegators ||
isFetchingMoreTornDelegators ||
!!hasMoreTornDelegators;
Comment on lines +153 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make delegator pagination deterministic

When a TORN voter has more than 1,000 delegators and multiple delegators have the same amount, this can mark the list complete while still missing addresses. The API applies offset pagination after ordering only by the aggregate amount (apps/api/src/repositories/delegations/delegators.ts:27-51), so tied rows can move between requests; getNextPageParam counts raw rows, while useDelegators and this modal deduplicate repeated addresses. The new pagination loop can therefore reach hasMoreTornDelegators === false after loading totalCount raw rows but fewer unique delegators, and castDelegatedVote silently omits the skipped voting power. Add a stable address tie-breaker to the API ordering or verify the unique-address count against totalCount before enabling submission.

Useful? React with 👍 / 👎.


const tornDelegatedVoteAddresses = useMemo(() => {
if (!isTorn || !address) return undefined;
Expand All @@ -136,9 +168,10 @@ export const VotingModal = ({
return true;
});

// Solo voter (no delegators): send an empty `from`. The TORN governor casts
// the voter's own balance separately and reverts on self-delegation, so the
// voter's own address must never appear in this list.
// `from` lists ONLY accounts that delegated to the voter; the governor
// reverts on self-delegation, so the voter's own address must never appear
// here. A solo voter (verified empty list) goes through castVote instead,
// since castDelegatedVote rejects an empty `from`.
return addresses;
}, [address, isTorn, tornDelegators]);

Expand Down Expand Up @@ -189,11 +222,16 @@ export const VotingModal = ({

const handleSubmit = async () => {
if (!address || !chain || !walletClient) return;
// Fail closed: an errored or still-paginating delegator query leaves a
// partial list that would otherwise read as complete and cast without the
// delegated voting power shown for this account.
if (isTorn && (isTornDelegatorListIncomplete || tornDelegatorsError))
return;
setIsLoading(true);
const hash = await voteOnProposal(
vote as "for" | "against" | "abstain",
proposal?.id as string,
address as unknown as Account,
{ address, type: "json-rpc" },
chain,
daoId,
walletClient,
Expand All @@ -215,7 +253,8 @@ export const VotingModal = ({
!vote ||
!walletClient ||
isLoading ||
(isTorn && isLoadingTornDelegators) ||
(isTorn &&
(isTornDelegatorListIncomplete || tornDelegatorsError !== null)) ||
!rawVotingPower ||
rawVotingPower === "0";

Expand Down
Loading
Loading