-
Notifications
You must be signed in to change notification settings - Fork 9
feat(dashboard): enable the Tornado Cash whitelabel with full governance #2136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ae55201
564725a
f388db8
0c66528
0639c48
634680b
ddd22fb
93d6e68
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@anticapture/dashboard": patch | ||
| --- | ||
|
|
||
| Fix Tornado Cash voting for accounts without delegators by using castVote instead of castDelegatedVote. |
| 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). |
| 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 |
|---|---|---|
|
|
@@ -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"; | ||
|
|
||
|
|
@@ -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, | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 ( Useful? React with 👍 / 👎. |
||
|
|
||
| const tornDelegatedVoteAddresses = useMemo(() => { | ||
| if (!isTorn || !address) return undefined; | ||
|
|
@@ -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]); | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -215,7 +253,8 @@ export const VotingModal = ({ | |
| !vote || | ||
| !walletClient || | ||
| isLoading || | ||
| (isTorn && isLoadingTornDelegators) || | ||
| (isTorn && | ||
| (isTornDelegatorListIncomplete || tornDelegatorsError !== null)) || | ||
| !rawVotingPower || | ||
| rawVotingPower === "0"; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.