From 7719392bf80763bb02b5f1632124ed694386f227 Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 25 Mar 2026 17:54:21 -0300 Subject: [PATCH 01/15] feat: add governance action modal and integrate queue/execute functionality --- .../modals/GovernanceActionModal.tsx | 200 ++++++++++++++++++ .../proposal-overview/ProposalHeader.tsx | 56 +++-- .../proposal-overview/ProposalSection.tsx | 62 +++++- .../utils/submitGovernanceAction.ts | 157 ++++++++++++++ .../delegate/DelegationModal.tsx | 8 +- .../delegate/utils/delegateTo.ts | 6 +- .../shared/services/wallet/wallet.ts | 8 +- 7 files changed, 461 insertions(+), 36 deletions(-) create mode 100644 apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx create mode 100644 apps/dashboard/features/governance/utils/submitGovernanceAction.ts diff --git a/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx b/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx new file mode 100644 index 000000000..3f852ec58 --- /dev/null +++ b/apps/dashboard/features/governance/components/modals/GovernanceActionModal.tsx @@ -0,0 +1,200 @@ +"use client"; + +import type { GetProposalQuery } from "@anticapture/graphql-client/hooks"; +import { Check, Hourglass, PenLine } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { useAccount, useWalletClient } from "wagmi"; + +import { showCustomToast } from "@/features/governance/utils/showCustomToast"; +import { + executeProposal, + queueProposal, + type GovernanceAction, +} from "@/features/governance/utils/submitGovernanceAction"; +import { Modal } from "@/shared/components/design-system/modal/Modal"; +import { SpinIcon } from "@/shared/components/icons/SpinIcon"; +import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault"; +import { cn } from "@/shared/utils/cn"; +import daoConfigByDaoId from "@/shared/dao-config"; +import type { DaoIdEnum } from "@/shared/types/daos"; + +type Proposal = NonNullable; + +type ActionStep = "waiting-signature" | "pending-tx" | "success" | "error"; + +interface GovernanceActionModalProps { + isOpen: boolean; + onClose: () => void; + action: GovernanceAction; + proposal: Proposal; + daoId: DaoIdEnum; +} + +export const GovernanceActionModal = ({ + isOpen, + onClose, + action, + proposal, + daoId, +}: GovernanceActionModalProps) => { + const [step, setStep] = useState("waiting-signature"); + const [error, setError] = useState(null); + + const { address } = useAccount(); + const chain = daoConfigByDaoId[daoId].daoOverview.chain; + const { data: walletClient } = useWalletClient({ chainId: chain.id }); + + const title = action === "queue" ? "Confirm Queue" : "Confirm Execution"; + const confirmLabel = + action === "queue" + ? "Confirm queuing in your wallet" + : "Confirm execution in your wallet"; + + const handleAction = useCallback(async () => { + if (!address || !walletClient) return; + if (step === "pending-tx" || step === "success") return; + + setError(null); + setStep("waiting-signature"); + + try { + const handler = action === "queue" ? queueProposal : executeProposal; + await handler( + proposal.targets, + proposal.values, + proposal.calldatas, + proposal.description, + address, + daoId, + walletClient, + () => setStep("pending-tx"), + ); + setStep("success"); + showCustomToast( + action === "queue" + ? "Proposal queued successfully!" + : "Proposal executed successfully!", + "success", + ); + onClose(); + setTimeout(() => window.location.reload(), 2000); + } catch (err) { + const message = err instanceof Error ? err.message : "Action failed."; + console.error("[GovernanceActionModal]", err); + let shortMessage: string; + if (message.includes("rejected") || message.includes("denied")) { + shortMessage = "Transaction rejected by user."; + } else if (message.includes("reverted")) { + shortMessage = + "Contract call reverted. The proposal may not be in the correct state."; + } else { + shortMessage = + message.split("\n")[0]?.slice(0, 100) ?? "Action failed."; + } + setError(shortMessage); + setStep("error"); + } + }, [address, walletClient, step, action, proposal, daoId, onClose, chain]); + + useEffect(() => { + if (!isOpen || !walletClient || step !== "waiting-signature") return; + handleAction(); + }, [isOpen, walletClient, handleAction, step]); + + const handleClose = () => { + setStep("waiting-signature"); + setError(null); + onClose(); + }; + + return ( + !open && handleClose()} + title={title} + > + {/* Proposal info */} +
+
+ + Proposal ID + + {proposal.id} +
+
+ + Proposal name + + + {proposal.title} + +
+
+ + {/* Stepper */} +
+ } + label={confirmLabel} + error={step === "error" ? error : undefined} + /> + + + + } + label="Wait for transaction to complete" + /> +
+
+ ); +}; + +interface StepRowProps { + done: boolean; + active: boolean; + icon: React.ReactNode; + label: string; + error?: string | null; +} + +const StepRow = ({ done, active, icon, label, error }: StepRowProps) => { + const getBackgroundColor = () => { + if (done) return "bg-surface-opacity-success"; + if (active) return "bg-primary"; + return "bg-border-default"; + }; + + return ( +
+
+
+ {active && ( + + )} +
+
+ {done ? : icon} +
+
+
+

{label}

+
+ + {error && ( +

+ {error} +

+ )} +
+ ); +}; diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx index 42306a65b..f1befa9ed 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx @@ -12,6 +12,8 @@ import type { DaoIdEnum } from "@/shared/types/daos"; interface ProposalHeaderProps { daoId: string; setIsVotingModalOpen: (isOpen: boolean) => void; + setIsQueueModalOpen: (isOpen: boolean) => void; + setIsExecuteModalOpen: (isOpen: boolean) => void; votingPower: string; votes: GetAccountPowerQuery["votesByProposalId"] | null; address: string | undefined; @@ -23,10 +25,15 @@ export const ProposalHeader = ({ votingPower, votes, setIsVotingModalOpen, + setIsQueueModalOpen, + setIsExecuteModalOpen, address, proposalStatus, }: ProposalHeaderProps) => { const supportValue = votes?.items[0]?.support; + const lowerStatus = proposalStatus.toLowerCase(); + + console.log({ proposalStatus, lowerStatus, supportValue }); return (
@@ -70,31 +77,44 @@ export const ProposalHeader = ({
)} - {/* If already voted: show voted badge */} + {/* Action buttons */} {address ? ( - supportValue === undefined ? ( - proposalStatus.toLowerCase() === "ongoing" && ( + <> + {lowerStatus === "ongoing" && + (supportValue === undefined ? ( + + ) : ( +
+
+ +
+ ))} + {lowerStatus === "succeeded" && ( - ) - ) : ( -
-
- -
- ) - ) : proposalStatus.toLowerCase() === "ongoing" ? ( -
- -
+ )} + {lowerStatus === "pending_execution" && ( + + )} + ) : (
- +
)}
diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx index a9a9700d9..5acb21e18 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalSection.tsx @@ -6,6 +6,7 @@ import { useParams } from "next/navigation"; import { useState, useCallback } from "react"; import { useAccount } from "wagmi"; +import { GovernanceActionModal } from "@/features/governance/components/modals/GovernanceActionModal"; import { VotingModal } from "@/features/governance/components/modals/VotingModal"; import { getVoteText, @@ -31,6 +32,8 @@ export const ProposalSection = () => { }>(); const { address } = useAccount(); const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); + const [isQueueModalOpen, setIsQueueModalOpen] = useState(false); + const [isExecuteModalOpen, setIsExecuteModalOpen] = useState(false); const [drawerAddress, setDrawerAddress] = useState(null); const daoEnum = daoId.toUpperCase() as DaoIdEnum; const { decimals } = daoConfig[daoEnum]; @@ -76,6 +79,8 @@ export const ProposalSection = () => { { daoId={daoEnum} /> + setIsQueueModalOpen(false)} + action="queue" + proposal={proposal} + daoId={daoEnum} + /> + + setIsExecuteModalOpen(false)} + action="execute" + proposal={proposal} + daoId={daoEnum} + /> + { {/* Fixed bottom bar for mobile voting */}
{address ? ( - supportValue === undefined ? ( - - ) : ( - - ) + <> + {proposal.status.toLowerCase() === "ongoing" && + (supportValue === undefined ? ( + + ) : ( + + ))} + {proposal.status.toLowerCase() === "succeeded" && ( + + )} + {proposal.status.toLowerCase() === "pending_execution" && ( + + )} + ) : ( )} diff --git a/apps/dashboard/features/governance/utils/submitGovernanceAction.ts b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts new file mode 100644 index 000000000..65b36e6b6 --- /dev/null +++ b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts @@ -0,0 +1,157 @@ +import { keccak256, publicActions, toHex } from "viem"; +import type { Address, Chain, WalletClient } from "viem"; +import type { DaoIdEnum } from "@/shared/types/daos"; +import daoConfigByDaoId from "@/shared/dao-config"; + +const GovernorQueueAbi = [ + { + name: "queue", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "targets", type: "address[]" }, + { name: "values", type: "uint256[]" }, + { name: "calldatas", type: "bytes[]" }, + { name: "descriptionHash", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +const GovernorExecuteAbi = [ + { + name: "execute", + type: "function", + stateMutability: "payable", + inputs: [ + { name: "targets", type: "address[]" }, + { name: "values", type: "uint256[]" }, + { name: "calldatas", type: "bytes[]" }, + { name: "descriptionHash", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +export type GovernanceAction = "queue" | "execute"; + +type ActionArgs = { + targets: `0x${string}`[]; + values: bigint[]; + calldatas: `0x${string}`[]; + descriptionHash: `0x${string}`; + account: Address; +}; + +const submitAction = async ( + action: GovernanceAction, + daoId: DaoIdEnum, + walletClient: WalletClient, + args: ActionArgs, + onTxHash: (hash: `0x${string}`) => void, +) => { + const client = walletClient.extend(publicActions); + const daoOverview = daoConfigByDaoId[daoId].daoOverview; + const address = daoOverview.contracts.governor; + if (!address) throw new Error("DAO governance address not found"); + const chain = daoOverview.chain as Chain; + + const contractArgs = [ + args.targets, + args.values, + args.calldatas, + args.descriptionHash, + ] as const; + + let hash: `0x${string}`; + if (action === "queue") { + const { request } = await client.simulateContract({ + abi: GovernorQueueAbi, + address, + functionName: "queue", + args: contractArgs, + account: args.account, + chain, + }); + hash = await client.writeContract(request); + } else { + const { request } = await client.simulateContract({ + abi: GovernorExecuteAbi, + address, + functionName: "execute", + args: contractArgs, + account: args.account, + value: 0n, + chain, + }); + hash = await client.writeContract(request); + } + onTxHash(hash); + + const receipt = await client.waitForTransactionReceipt({ hash }); + return receipt; +}; + +const toActionArgs = ( + proposalTargets: (string | null)[], + proposalValues: (string | null)[], + proposalCalldatas: (string | null)[], + description: string, + account: Address, +): ActionArgs => { + return { + targets: proposalTargets.map((t) => (t ?? "0x") as `0x${string}`), + values: proposalValues.map((v) => BigInt(v ?? "0")), + calldatas: proposalCalldatas.map((c) => (c ?? "0x") as `0x${string}`), + descriptionHash: keccak256(toHex(description)), + account, + }; +}; + +export const queueProposal = ( + proposalTargets: (string | null)[], + proposalValues: (string | null)[], + proposalCalldatas: (string | null)[], + description: string, + account: Address, + daoId: DaoIdEnum, + walletClient: WalletClient, + onTxHash: (hash: `0x${string}`) => void, +) => + submitAction( + "queue", + daoId, + walletClient, + toActionArgs( + proposalTargets, + proposalValues, + proposalCalldatas, + description, + account, + ), + onTxHash, + ); + +export const executeProposal = ( + proposalTargets: (string | null)[], + proposalValues: (string | null)[], + proposalCalldatas: (string | null)[], + description: string, + account: Address, + daoId: DaoIdEnum, + walletClient: WalletClient, + onTxHash: (hash: `0x${string}`) => void, +) => + submitAction( + "execute", + daoId, + walletClient, + toActionArgs( + proposalTargets, + proposalValues, + proposalCalldatas, + description, + account, + ), + onTxHash, + ); diff --git a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx index 9c4415479..faf0911b9 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx +++ b/apps/dashboard/features/holders-and-delegates/delegate/DelegationModal.tsx @@ -2,7 +2,7 @@ import { Check, Hourglass, PenLine } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; -import type { Account, Address } from "viem"; +import type { Address } from "viem"; import { formatUnits } from "viem"; import { useAccount, useReadContract, useWalletClient } from "wagmi"; @@ -45,7 +45,6 @@ export const DelegationModal = ({ onSuccess, }: DelegationModalProps) => { const { address: userAddress } = useAccount(); - const { data: walletClient } = useWalletClient(); const [step, setStep] = useState("waiting-signature"); const [error, setError] = useState(null); @@ -53,6 +52,8 @@ export const DelegationModal = ({ const tokenAddress = daoConfig?.daoOverview?.contracts?.token as | Address | undefined; + const chain = daoConfig?.daoOverview?.chain; + const { data: walletClient } = useWalletClient({ chainId: chain?.id }); const decimals = daoConfig?.decimals ?? 18; const { data: votingPowerRaw } = useReadContract({ @@ -79,9 +80,10 @@ export const DelegationModal = ({ await delegateTo( tokenAddress, delegateAddress, - userAddress as unknown as Account, + userAddress, walletClient, () => setStep("pending-tx"), + chain, ); setStep("success"); showCustomToast("Delegation successful!", "success"); 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 471cffb27..fde52ed43 100644 --- a/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts +++ b/apps/dashboard/features/holders-and-delegates/delegate/utils/delegateTo.ts @@ -1,5 +1,5 @@ import { publicActions } from "viem"; -import type { Account, Address, WalletClient } from "viem"; +import type { Address, Chain, WalletClient } from "viem"; const ERC20VotesAbi = [ { @@ -14,9 +14,10 @@ const ERC20VotesAbi = [ export const delegateTo = async ( tokenAddress: Address, delegateAddress: Address, - account: Account, + account: Address, walletClient: WalletClient, onTxHash: (hash: `0x${string}`) => void, + chain?: Chain, ) => { const client = walletClient.extend(publicActions); @@ -26,6 +27,7 @@ export const delegateTo = async ( functionName: "delegate", args: [delegateAddress], account, + chain, }); const hash = await client.writeContract(request); diff --git a/apps/dashboard/shared/services/wallet/wallet.ts b/apps/dashboard/shared/services/wallet/wallet.ts index edee2ebb7..1a8b6efcc 100644 --- a/apps/dashboard/shared/services/wallet/wallet.ts +++ b/apps/dashboard/shared/services/wallet/wallet.ts @@ -8,11 +8,13 @@ import { } from "@rainbow-me/rainbowkit/wallets"; import { createWalletClient } from "viem"; import { createConfig, http } from "wagmi"; -import { mainnet } from "wagmi/chains"; +import { mainnet, optimism, scroll } from "wagmi/chains"; const alchemyApiKey = process.env.NEXT_PUBLIC_ALCHEMY_KEY; export const rpcHttpUrl = `https://eth-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; +const rpcOptimismUrl = `https://opt-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; +const rpcScrollUrl = `https://scroll-mainnet.g.alchemy.com/v2/${alchemyApiKey}`; export const walletClient = createWalletClient({ chain: mainnet, @@ -46,9 +48,11 @@ const connectors = connectorsForWallets( const wagmiConfig = createConfig({ connectors, - chains: [mainnet], + chains: [mainnet, optimism, scroll], transports: { [mainnet.id]: http(rpcHttpUrl), + [optimism.id]: http(rpcOptimismUrl), + [scroll.id]: http(rpcScrollUrl), }, }); From 84bb9a53018f0b04878c917ca96f04da20c39e8c Mon Sep 17 00:00:00 2001 From: Bruno Date: Wed, 25 Mar 2026 18:03:00 -0300 Subject: [PATCH 02/15] fix: normalize proposal status to lowercase for consistent button visibility --- .../components/proposal-overview/ProposalHeader.tsx | 9 +++------ .../components/proposal-overview/ProposalSection.tsx | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx index f1befa9ed..ba1fb2e03 100644 --- a/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx +++ b/apps/dashboard/features/governance/components/proposal-overview/ProposalHeader.tsx @@ -31,9 +31,6 @@ export const ProposalHeader = ({ proposalStatus, }: ProposalHeaderProps) => { const supportValue = votes?.items[0]?.support; - const lowerStatus = proposalStatus.toLowerCase(); - - console.log({ proposalStatus, lowerStatus, supportValue }); return (
@@ -80,7 +77,7 @@ export const ProposalHeader = ({ {/* Action buttons */} {address ? ( <> - {lowerStatus === "ongoing" && + {proposalStatus === "ongoing" && (supportValue === undefined ? (
))} - {lowerStatus === "succeeded" && ( + {proposalStatus === "succeeded" && ( )} - {lowerStatus === "pending_execution" && ( + {proposalStatus === "pending_execution" && ( - {/* )} */} - {/* {proposalStatus === "pending_execution" && ( */} - - {/* )} */} + {address && + proposalStatus === "succeeded" && + daoId.toUpperCase() !== DaoIdEnum.SHU && ( + + )} + {address && + (proposalStatus === "pending_execution" || + proposalStatus === "queued" || + (proposalStatus === "succeeded" && + daoId.toUpperCase() === DaoIdEnum.SHU)) && ( + + )} )}
diff --git a/apps/dashboard/features/governance/utils/submitGovernanceAction.ts b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts index 65b36e6b6..09a60575b 100644 --- a/apps/dashboard/features/governance/utils/submitGovernanceAction.ts +++ b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts @@ -1,6 +1,6 @@ import { keccak256, publicActions, toHex } from "viem"; import type { Address, Chain, WalletClient } from "viem"; -import type { DaoIdEnum } from "@/shared/types/daos"; +import { DaoIdEnum } from "@/shared/types/daos"; import daoConfigByDaoId from "@/shared/dao-config"; const GovernorQueueAbi = [ @@ -33,6 +33,26 @@ const GovernorExecuteAbi = [ }, ] as const; +const AzoriusExecuteAbi = [ + { + name: "executeProposal", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { internalType: "uint32", name: "_proposalId", type: "uint32" }, + { internalType: "address[]", name: "_targets", type: "address[]" }, + { internalType: "uint256[]", name: "_values", type: "uint256[]" }, + { internalType: "bytes[]", name: "_data", type: "bytes[]" }, + { + internalType: "enum Enum.Operation[]", + name: "_operations", + type: "uint8[]", + }, + ], + outputs: [], + }, +] as const; + export type GovernanceAction = "queue" | "execute"; type ActionArgs = { @@ -41,8 +61,108 @@ type ActionArgs = { calldatas: `0x${string}`[]; descriptionHash: `0x${string}`; account: Address; + proposalId: string; +}; + +type ActionHandler = ( + client: ReturnType, + address: `0x${string}`, + chain: Chain, + args: ActionArgs, +) => Promise<`0x${string}`>; + +/** + * OZ Governor: queue(targets, values, calldatas, descriptionHash) + */ +const ozQueueHandler: ActionHandler = async (client, address, chain, args) => { + const contractArgs = [ + args.targets, + args.values, + args.calldatas, + args.descriptionHash, + ] as const; + const { request } = await client.simulateContract({ + abi: GovernorQueueAbi, + address, + functionName: "queue", + args: contractArgs, + account: args.account, + chain, + }); + return client.writeContract(request); +}; + +/** + * OZ Governor: execute(targets, values, calldatas, descriptionHash) + */ +const ozExecuteHandler: ActionHandler = async ( + client, + address, + chain, + args, +) => { + const contractArgs = [ + args.targets, + args.values, + args.calldatas, + args.descriptionHash, + ] as const; + const { request } = await client.simulateContract({ + abi: GovernorExecuteAbi, + address, + functionName: "execute", + args: contractArgs, + account: args.account, + value: 0n, + chain, + }); + return client.writeContract(request); }; +/** + * Azorius: executeProposal(proposalId, targets, values, data, operations) + * Azorius has no queue step — proposals execute directly after voting ends. + */ +const azoriusExecuteHandler: ActionHandler = async ( + client, + address, + chain, + args, +) => { + // All operations are Call (0) for standard governance proposals + const operations = args.targets.map(() => 0); + const { request } = await client.simulateContract({ + abi: AzoriusExecuteAbi, + address, + functionName: "executeProposal", + args: [ + Number(args.proposalId), + args.targets, + args.values, + args.calldatas, + operations, + ], + account: args.account, + chain, + }); + return client.writeContract(request); +}; + +function getActionHandler( + action: GovernanceAction, + daoId: DaoIdEnum, +): ActionHandler { + switch (daoId) { + case DaoIdEnum.SHU: + if (action === "queue") { + throw new Error("Queue is not supported for Azorius governance (SHU)"); + } + return azoriusExecuteHandler; + default: + return action === "queue" ? ozQueueHandler : ozExecuteHandler; + } +} + const submitAction = async ( action: GovernanceAction, daoId: DaoIdEnum, @@ -56,36 +176,8 @@ const submitAction = async ( if (!address) throw new Error("DAO governance address not found"); const chain = daoOverview.chain as Chain; - const contractArgs = [ - args.targets, - args.values, - args.calldatas, - args.descriptionHash, - ] as const; - - let hash: `0x${string}`; - if (action === "queue") { - const { request } = await client.simulateContract({ - abi: GovernorQueueAbi, - address, - functionName: "queue", - args: contractArgs, - account: args.account, - chain, - }); - hash = await client.writeContract(request); - } else { - const { request } = await client.simulateContract({ - abi: GovernorExecuteAbi, - address, - functionName: "execute", - args: contractArgs, - account: args.account, - value: 0n, - chain, - }); - hash = await client.writeContract(request); - } + const handler = getActionHandler(action, daoId); + const hash = await handler(client, address, chain, args); onTxHash(hash); const receipt = await client.waitForTransactionReceipt({ hash }); @@ -98,6 +190,7 @@ const toActionArgs = ( proposalCalldatas: (string | null)[], description: string, account: Address, + proposalId: string, ): ActionArgs => { return { targets: proposalTargets.map((t) => (t ?? "0x") as `0x${string}`), @@ -105,6 +198,7 @@ const toActionArgs = ( calldatas: proposalCalldatas.map((c) => (c ?? "0x") as `0x${string}`), descriptionHash: keccak256(toHex(description)), account, + proposalId, }; }; @@ -117,6 +211,7 @@ export const queueProposal = ( daoId: DaoIdEnum, walletClient: WalletClient, onTxHash: (hash: `0x${string}`) => void, + proposalId: string, ) => submitAction( "queue", @@ -128,6 +223,7 @@ export const queueProposal = ( proposalCalldatas, description, account, + proposalId, ), onTxHash, ); @@ -141,6 +237,7 @@ export const executeProposal = ( daoId: DaoIdEnum, walletClient: WalletClient, onTxHash: (hash: `0x${string}`) => void, + proposalId: string, ) => submitAction( "execute", @@ -152,6 +249,7 @@ export const executeProposal = ( proposalCalldatas, description, account, + proposalId, ), onTxHash, ); From 93f0ac28b70d2854ed11eac5742ed5a82076d683 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 6 Apr 2026 17:55:38 -0300 Subject: [PATCH 05/15] refactor: streamline submitAction logic for governance proposals --- .../utils/submitGovernanceAction.ts | 159 +++++++----------- 1 file changed, 59 insertions(+), 100 deletions(-) diff --git a/apps/dashboard/features/governance/utils/submitGovernanceAction.ts b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts index 09a60575b..7d317bd52 100644 --- a/apps/dashboard/features/governance/utils/submitGovernanceAction.ts +++ b/apps/dashboard/features/governance/utils/submitGovernanceAction.ts @@ -64,122 +64,81 @@ type ActionArgs = { proposalId: string; }; -type ActionHandler = ( - client: ReturnType, - address: `0x${string}`, - chain: Chain, +const submitAction = async ( + action: GovernanceAction, + daoId: DaoIdEnum, + walletClient: WalletClient, args: ActionArgs, -) => Promise<`0x${string}`>; + onTxHash: (hash: `0x${string}`) => void, +) => { + const client = walletClient.extend(publicActions); + const daoOverview = daoConfigByDaoId[daoId].daoOverview; + const address = daoOverview.contracts.governor; + if (!address) throw new Error("DAO governance address not found"); + const chain = daoOverview.chain as Chain; -/** - * OZ Governor: queue(targets, values, calldatas, descriptionHash) - */ -const ozQueueHandler: ActionHandler = async (client, address, chain, args) => { - const contractArgs = [ + const ozContractArgs = [ args.targets, args.values, args.calldatas, args.descriptionHash, ] as const; - const { request } = await client.simulateContract({ - abi: GovernorQueueAbi, - address, - functionName: "queue", - args: contractArgs, - account: args.account, - chain, - }); - return client.writeContract(request); -}; -/** - * OZ Governor: execute(targets, values, calldatas, descriptionHash) - */ -const ozExecuteHandler: ActionHandler = async ( - client, - address, - chain, - args, -) => { - const contractArgs = [ - args.targets, - args.values, - args.calldatas, - args.descriptionHash, - ] as const; - const { request } = await client.simulateContract({ - abi: GovernorExecuteAbi, - address, - functionName: "execute", - args: contractArgs, - account: args.account, - value: 0n, - chain, - }); - return client.writeContract(request); -}; + let hash: `0x${string}`; -/** - * Azorius: executeProposal(proposalId, targets, values, data, operations) - * Azorius has no queue step — proposals execute directly after voting ends. - */ -const azoriusExecuteHandler: ActionHandler = async ( - client, - address, - chain, - args, -) => { - // All operations are Call (0) for standard governance proposals - const operations = args.targets.map(() => 0); - const { request } = await client.simulateContract({ - abi: AzoriusExecuteAbi, - address, - functionName: "executeProposal", - args: [ - Number(args.proposalId), - args.targets, - args.values, - args.calldatas, - operations, - ], - account: args.account, - chain, - }); - return client.writeContract(request); -}; - -function getActionHandler( - action: GovernanceAction, - daoId: DaoIdEnum, -): ActionHandler { switch (daoId) { - case DaoIdEnum.SHU: + case DaoIdEnum.SHU: { if (action === "queue") { throw new Error("Queue is not supported for Azorius governance (SHU)"); } - return azoriusExecuteHandler; - default: - return action === "queue" ? ozQueueHandler : ozExecuteHandler; + // Azorius: executeProposal(proposalId, targets, values, data, operations) + // All operations are Call (0) for standard governance proposals + const operations = args.targets.map(() => 0); + const { request } = await client.simulateContract({ + abi: AzoriusExecuteAbi, + address, + functionName: "executeProposal", + args: [ + Number(args.proposalId), + args.targets, + args.values, + args.calldatas, + operations, + ], + account: args.account, + chain, + }); + hash = await client.writeContract(request); + break; + } + default: { + if (action === "queue") { + const { request } = await client.simulateContract({ + abi: GovernorQueueAbi, + address, + functionName: "queue", + args: ozContractArgs, + account: args.account, + chain, + }); + hash = await client.writeContract(request); + } else { + const { request } = await client.simulateContract({ + abi: GovernorExecuteAbi, + address, + functionName: "execute", + args: ozContractArgs, + account: args.account, + value: 0n, + chain, + }); + hash = await client.writeContract(request); + } + break; + } } -} - -const submitAction = async ( - action: GovernanceAction, - daoId: DaoIdEnum, - walletClient: WalletClient, - args: ActionArgs, - onTxHash: (hash: `0x${string}`) => void, -) => { - const client = walletClient.extend(publicActions); - const daoOverview = daoConfigByDaoId[daoId].daoOverview; - const address = daoOverview.contracts.governor; - if (!address) throw new Error("DAO governance address not found"); - const chain = daoOverview.chain as Chain; - const handler = getActionHandler(action, daoId); - const hash = await handler(client, address, chain, args); onTxHash(hash); - const receipt = await client.waitForTransactionReceipt({ hash }); return receipt; }; From 614155a896faf1defe099e5574eb8b47bf10d1f6 Mon Sep 17 00:00:00 2001 From: Bruno Date: Mon, 6 Apr 2026 22:21:11 -0300 Subject: [PATCH 06/15] feat: implement mobile layout for governance and proposal pages --- .../offchain-proposal/[proposalId]/page.tsx | 0 .../governance/opengraph-image.tsx | 0 .../governance/page.tsx | 28 ++----------- .../proposal/[proposalId]/opengraph-image.tsx | 0 .../governance/proposal/[proposalId]/page.tsx | 23 +---------- .../governance-overview/GovernanceSection.tsx | 2 +- .../dropdowns/HeaderDAOSidebarDropdown.tsx | 8 +++- apps/dashboard/widgets/HeaderMobile.tsx | 3 +- apps/dashboard/widgets/HeaderNavMobile.tsx | 40 ++++++++++++++----- apps/dashboard/widgets/StickyPageHeader.tsx | 6 ++- 10 files changed, 49 insertions(+), 61 deletions(-) rename apps/dashboard/app/[daoId]/{(secondary) => (main)}/governance/offchain-proposal/[proposalId]/page.tsx (100%) rename apps/dashboard/app/[daoId]/{(secondary) => (main)}/governance/opengraph-image.tsx (100%) rename apps/dashboard/app/[daoId]/{(secondary) => (main)}/governance/page.tsx (59%) rename apps/dashboard/app/[daoId]/{(secondary) => (main)}/governance/proposal/[proposalId]/opengraph-image.tsx (100%) rename apps/dashboard/app/[daoId]/{(secondary) => (main)}/governance/proposal/[proposalId]/page.tsx (64%) diff --git a/apps/dashboard/app/[daoId]/(secondary)/governance/offchain-proposal/[proposalId]/page.tsx b/apps/dashboard/app/[daoId]/(main)/governance/offchain-proposal/[proposalId]/page.tsx similarity index 100% rename from apps/dashboard/app/[daoId]/(secondary)/governance/offchain-proposal/[proposalId]/page.tsx rename to apps/dashboard/app/[daoId]/(main)/governance/offchain-proposal/[proposalId]/page.tsx diff --git a/apps/dashboard/app/[daoId]/(secondary)/governance/opengraph-image.tsx b/apps/dashboard/app/[daoId]/(main)/governance/opengraph-image.tsx similarity index 100% rename from apps/dashboard/app/[daoId]/(secondary)/governance/opengraph-image.tsx rename to apps/dashboard/app/[daoId]/(main)/governance/opengraph-image.tsx diff --git a/apps/dashboard/app/[daoId]/(secondary)/governance/page.tsx b/apps/dashboard/app/[daoId]/(main)/governance/page.tsx similarity index 59% rename from apps/dashboard/app/[daoId]/(secondary)/governance/page.tsx rename to apps/dashboard/app/[daoId]/(main)/governance/page.tsx index 4bc964e70..0d6c6c753 100644 --- a/apps/dashboard/app/[daoId]/(secondary)/governance/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/governance/page.tsx @@ -1,10 +1,7 @@ import type { Metadata } from "next"; import { GovernanceSection } from "@/features/governance"; -import { Footer } from "@/shared/components/design-system/footer/Footer"; import type { DaoIdEnum } from "@/shared/types/daos"; -import { HeaderDAOSidebar, HeaderSidebar, StickyPageHeader } from "@/widgets"; -import { HeaderMobile } from "@/widgets/HeaderMobile"; type Props = { params: Promise<{ daoId: string }>; @@ -33,29 +30,10 @@ export async function generateMetadata(props: Props): Promise { }; } -export default function DaoPage() { +export default function GovernancePage() { return ( -
-
-
- -
-
- -
-
-
-
- - -
-
-
- -
-
-
-
+
+
); } diff --git a/apps/dashboard/app/[daoId]/(secondary)/governance/proposal/[proposalId]/opengraph-image.tsx b/apps/dashboard/app/[daoId]/(main)/governance/proposal/[proposalId]/opengraph-image.tsx similarity index 100% rename from apps/dashboard/app/[daoId]/(secondary)/governance/proposal/[proposalId]/opengraph-image.tsx rename to apps/dashboard/app/[daoId]/(main)/governance/proposal/[proposalId]/opengraph-image.tsx diff --git a/apps/dashboard/app/[daoId]/(secondary)/governance/proposal/[proposalId]/page.tsx b/apps/dashboard/app/[daoId]/(main)/governance/proposal/[proposalId]/page.tsx similarity index 64% rename from apps/dashboard/app/[daoId]/(secondary)/governance/proposal/[proposalId]/page.tsx rename to apps/dashboard/app/[daoId]/(main)/governance/proposal/[proposalId]/page.tsx index 6ade17911..d048405a7 100644 --- a/apps/dashboard/app/[daoId]/(secondary)/governance/proposal/[proposalId]/page.tsx +++ b/apps/dashboard/app/[daoId]/(main)/governance/proposal/[proposalId]/page.tsx @@ -1,10 +1,7 @@ import type { Metadata } from "next"; import { ProposalSection } from "@/features/governance/components/proposal-overview/ProposalSection"; -import { Footer } from "@/shared/components/design-system/footer/Footer"; import type { DaoIdEnum } from "@/shared/types/daos"; -import { HeaderSidebar, StickyPageHeader } from "@/widgets"; -import { HeaderMobile } from "@/widgets/HeaderMobile"; type Props = { params: Promise<{ daoId: string; proposalId: string }>; @@ -35,24 +32,8 @@ export async function generateMetadata(props: Props): Promise { export default function ProposalPage() { return ( -
-
-
- -
-
-
-
- - -
-
-
- -
-
-
-
+
+
); } diff --git a/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx b/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx index cc16d96a6..164d58215 100644 --- a/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx +++ b/apps/dashboard/features/governance/components/governance-overview/GovernanceSection.tsx @@ -130,7 +130,7 @@ export const GovernanceSection = () => { } return ( -
+
} diff --git a/apps/dashboard/shared/components/dropdowns/HeaderDAOSidebarDropdown.tsx b/apps/dashboard/shared/components/dropdowns/HeaderDAOSidebarDropdown.tsx index 7735849ff..274040624 100644 --- a/apps/dashboard/shared/components/dropdowns/HeaderDAOSidebarDropdown.tsx +++ b/apps/dashboard/shared/components/dropdowns/HeaderDAOSidebarDropdown.tsx @@ -21,11 +21,13 @@ type DropdownItem = { export interface HeaderDAOSidebarDropdownProps { isCollapsed?: boolean; onToggleCollapse?: () => void; + onOpenChange?: (isOpen: boolean) => void; } export const HeaderDAOSidebarDropdown = ({ isCollapsed = false, onToggleCollapse, + onOpenChange, }: HeaderDAOSidebarDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); @@ -40,17 +42,19 @@ export const HeaderDAOSidebarDropdown = ({ if (!isOpen) return; if (closeTimerRef.current) clearTimeout(closeTimerRef.current); setIsClosing(true); + onOpenChange?.(false); closeTimerRef.current = setTimeout(() => { setIsOpen(false); setIsClosing(false); }, ANIMATION_DURATION); - }, [isOpen]); + }, [isOpen, onOpenChange]); const open = useCallback(() => { if (closeTimerRef.current) clearTimeout(closeTimerRef.current); setIsClosing(false); setIsOpen(true); - }, []); + onOpenChange?.(true); + }, [onOpenChange]); const toggle = useCallback(() => { if (isOpen && !isClosing) { diff --git a/apps/dashboard/widgets/HeaderMobile.tsx b/apps/dashboard/widgets/HeaderMobile.tsx index 7784a13e9..c07160888 100644 --- a/apps/dashboard/widgets/HeaderMobile.tsx +++ b/apps/dashboard/widgets/HeaderMobile.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; + import { useState, useEffect, useMemo } from "react"; import { ButtonHeaderSidebar, ConnectWallet } from "@/shared/components"; @@ -143,7 +144,7 @@ export const HeaderMobile = ({
{ const { daoId }: { daoId: string } = useParams(); + const pathname = usePathname(); + const router = useRouter(); + if (!daoId) { return null; } @@ -34,8 +37,7 @@ export const HeaderNavMobile = () => { { page: PAGES_CONSTANTS.activityFeed.page, title: PAGES_CONSTANTS.activityFeed.title, - enabled: daoConfig.activityFeed, - isNew: true, + enabled: !!daoConfig.activityFeed, }, { page: PAGES_CONSTANTS.attackProfitability.page, @@ -61,15 +63,35 @@ export const HeaderNavMobile = () => { page: PAGES_CONSTANTS.serviceProviders.page, title: PAGES_CONSTANTS.serviceProviders.title, enabled: !!daoConfig.serviceProviders, - isNew: true, }, ]; + const items = options + .filter((o) => o.enabled) + .map((o) => ({ value: o.page, label: o.title })); + + const isDaoOverviewPage = + pathname === `/${daoId}` || pathname === `/${daoId}/`; + const currentPage = isDaoOverviewPage + ? "/" + : (pathname?.split("/").filter(Boolean).pop() ?? "/"); + + const handleChange = (value: string) => { + if (value === "/") { + router.push(`/${daoId}`); + } else { + router.push(`/${daoId}/${value}`); + } + }; + return ( -
-
- -
+
+