diff --git a/.env.example b/.env.example index 6ec0ab3..c5a1ea2 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,8 @@ NEXT_PUBLIC_TOKEN_ADDRESS=0x9A3218197C77F54BB2510FBBcc7Da3A4D2bE0DeE # Plugin addresses NEXT_PUBLIC_TOKEN_VOTING_PLUGIN_ADDRESS=0x7AdF2545e746E014887916e476DfCB3Fb57D78b0 NEXT_PUBLIC_LOCK_TO_VOTE_PLUGIN_ADDRESS= -NEXT_PUBLIC_MULTISIG_PLUGIN_ADDRESS=0xf49d54D40A331441536BDF74C44FFb527cf113c9 +NEXT_PUBLIC_MULTISIG_PLUGIN_ADDRESS=0xA4371a239D08bfBA6E8894eccf8466C6323A52C3 +NEXT_PUBLIC_OPT_MULTISIG_PLUGIN_ADDRESS=0xf49d54D40A331441536BDF74C44FFb527cf113c9 NEXT_PUBLIC_EMERGENCY_MULTISIG_PLUGIN_ADDRESS=0x3abd07A24a39eCEB2a701f3C4A5BBbcb7069460D NEXT_PUBLIC_DUAL_GOVERNANCE_PLUGIN_ADDRESS=0x31df2Cf73f36732c10523E4F228a458292B8F6DF NEXT_PUBLIC_PUBLIC_KEY_REGISTRY_CONTRACT_ADDRESS=0x4BA2de07E5B7FB284d363DBb4c481F330c25b2A5 diff --git a/constants.ts b/constants.ts index 773e35f..822a0b6 100644 --- a/constants.ts +++ b/constants.ts @@ -6,6 +6,7 @@ export const PUB_DAO_ADDRESS = (process.env.NEXT_PUBLIC_DAO_ADDRESS ?? "") as Ad export const PUB_TOKEN_ADDRESS = (process.env.NEXT_PUBLIC_TOKEN_ADDRESS ?? "") as Address; export const PUB_MULTISIG_PLUGIN_ADDRESS = (process.env.NEXT_PUBLIC_MULTISIG_PLUGIN_ADDRESS ?? "") as Address; +export const PUB_OPT_MULTISIG_PLUGIN_ADDRESS = (process.env.NEXT_PUBLIC_OPT_MULTISIG_PLUGIN_ADDRESS ?? "") as Address; export const PUB_EMERGENCY_MULTISIG_PLUGIN_ADDRESS = (process.env.NEXT_PUBLIC_EMERGENCY_MULTISIG_PLUGIN_ADDRESS ?? "") as Address; export const PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS = (process.env.NEXT_PUBLIC_DUAL_GOVERNANCE_PLUGIN_ADDRESS ?? diff --git a/plugins/emergency-multisig/hooks/useProposalApprovals.ts b/plugins/emergency-multisig/hooks/useProposalApprovals.ts index d6c5fe5..4a4957f 100644 --- a/plugins/emergency-multisig/hooks/useProposalApprovals.ts +++ b/plugins/emergency-multisig/hooks/useProposalApprovals.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { Address, getAbiItem } from "viem"; import { usePublicClient } from "wagmi"; -import { ApprovedEvent, ApprovedEventResponse, EmergencyProposal } from "../utils/types"; +import { ApprovedEvent, EmergencyProposal } from "../utils/types"; import { EmergencyMultisigPluginAbi } from "../artifacts/EmergencyMultisigPlugin"; import { PUB_CHAIN } from "@/constants"; @@ -17,7 +17,7 @@ export function useProposalApprovals(pluginAddress: Address, proposalId: string, async function getLogs() { if (!publicClient || !proposal?.parameters?.snapshotBlock) return; - const logs: ApprovedEventResponse[] = (await publicClient.getLogs({ + const logs = await publicClient.getLogs({ address: pluginAddress, event: event, args: { @@ -25,7 +25,7 @@ export function useProposalApprovals(pluginAddress: Address, proposalId: string, }, fromBlock: proposal.parameters.snapshotBlock, toBlock: "latest", // TODO: Make this variable between 'latest' and proposal last block - })) as any; + }); const newLogs = logs.flatMap((log) => log.args); if (newLogs.length > proposalLogs.length) setLogs(newLogs); diff --git a/plugins/emergency-multisig/hooks/useProposalVariantStatus.ts b/plugins/emergency-multisig/hooks/useProposalVariantStatus.ts index 316d783..0738d70 100644 --- a/plugins/emergency-multisig/hooks/useProposalVariantStatus.ts +++ b/plugins/emergency-multisig/hooks/useProposalVariantStatus.ts @@ -1,22 +1,26 @@ import { useState, useEffect } from "react"; +import { EmergencyProposal } from "@/plugins/emergency-multisig/utils/types"; import { ProposalStatus } from "@aragon/ods"; -import dayjs from "dayjs"; -import { EmergencyProposal } from "../utils/types"; export const useProposalVariantStatus = (proposal: EmergencyProposal) => { const [status, setStatus] = useState({ variant: "", label: "" }); useEffect(() => { if (!proposal || !proposal?.parameters) return; - setStatus( - proposal?.approvals >= proposal?.parameters?.minApprovals - ? proposal?.executed - ? { variant: "success", label: "Executed" } - : { variant: "success", label: "Executable" } - : dayjs().isAfter(dayjs(Number(proposal?.parameters.expirationDate) * 1000)) - ? { variant: "critical", label: "Failed" } - : { variant: "info", label: "Active" } - ); + + if (proposal?.executed) { + setStatus({ variant: "primary", label: "Executed" }); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.expirationDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus({ variant: "critical", label: "Defeated" }); + } else { + setStatus({ variant: "critical", label: "Expired" }); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus({ variant: "success", label: "Executable" }); + } else { + setStatus({ variant: "info", label: "Active" }); + } }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); return status; @@ -27,15 +31,20 @@ export const useProposalStatus = (proposal: EmergencyProposal) => { useEffect(() => { if (!proposal || !proposal?.parameters) return; - setStatus( - proposal?.approvals >= proposal?.parameters?.minApprovals - ? proposal?.executed - ? ProposalStatus.EXECUTED - : ProposalStatus.ACCEPTED - : dayjs().isAfter(dayjs(Number(proposal?.parameters.expirationDate) * 1000)) - ? ProposalStatus.FAILED - : ProposalStatus.ACTIVE - ); + + if (proposal?.executed) { + setStatus(ProposalStatus.EXECUTED); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.expirationDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus(ProposalStatus.REJECTED); + } else { + setStatus(ProposalStatus.EXPIRED); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus(ProposalStatus.EXECUTABLE); + } else { + setStatus(ProposalStatus.ACTIVE); + } }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); return status; diff --git a/plugins/emergency-multisig/utils/types.tsx b/plugins/emergency-multisig/utils/types.tsx index 145b8e8..b6fc826 100644 --- a/plugins/emergency-multisig/utils/types.tsx +++ b/plugins/emergency-multisig/utils/types.tsx @@ -43,11 +43,7 @@ export type EmergencyProposal = { resources: IProposalResource[]; }; -export type ApprovedEventResponse = { - args: ApprovedEvent[]; -}; - export type ApprovedEvent = { - proposalId: bigint; - approver: Address; + proposalId?: bigint; + approver?: Address; }; diff --git a/plugins/index.ts b/plugins/index.ts index 37cf62b..c5c8e6d 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -5,6 +5,7 @@ import { PUB_EMERGENCY_MULTISIG_PLUGIN_ADDRESS, PUB_TOKEN_VOTING_PLUGIN_ADDRESS, PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS, + PUB_OPT_MULTISIG_PLUGIN_ADDRESS, } from "@/constants"; import { IconType } from "@aragon/ods"; @@ -20,6 +21,13 @@ type PluginItem = { }; export const plugins: PluginItem[] = [ + { + id: "multisig", + folderName: "multisig", + title: "Multisig", + // icon: IconType.BLOCKCHAIN_BLOCKCHAIN, + pluginAddress: PUB_MULTISIG_PLUGIN_ADDRESS, + }, { id: "token-voting", folderName: "tokenVoting", @@ -42,11 +50,11 @@ export const plugins: PluginItem[] = [ pluginAddress: PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, }, { - id: "multisig", - folderName: "multisig", - title: "Multisig", + id: "opt-multisig", + folderName: "opt-multisig", + title: "Multisig (Optimistic)", // icon: IconType.BLOCKCHAIN_BLOCKCHAIN, - pluginAddress: PUB_MULTISIG_PLUGIN_ADDRESS, + pluginAddress: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, }, { id: "emergency", diff --git a/plugins/lockToVote/components/vote/vetoes-section.tsx b/plugins/lockToVote/components/vote/vetoes-section.tsx index fcaee69..e66b52c 100644 --- a/plugins/lockToVote/components/vote/vetoes-section.tsx +++ b/plugins/lockToVote/components/vote/vetoes-section.tsx @@ -28,10 +28,12 @@ const VetoCard = function ({ veto }: { veto: VetoCastEvent }) {
- +
{veto?.voter} -

{compactNumber(formatUnits(veto.votingPower, 18))} votes

+

+ {compactNumber(formatUnits(veto.votingPower ?? BigInt(0), 18))} votes +

diff --git a/plugins/lockToVote/hooks/useProposalVariantStatus.ts b/plugins/lockToVote/hooks/useProposalVariantStatus.ts index d24aac6..a1c9875 100644 --- a/plugins/lockToVote/hooks/useProposalVariantStatus.ts +++ b/plugins/lockToVote/hooks/useProposalVariantStatus.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Proposal } from "@/plugins/lockToVote/utils/types"; +import { Proposal } from "../utils/types"; import { ProposalStatus } from "@aragon/ods"; import { useToken } from "./useToken"; @@ -9,18 +9,17 @@ export const useProposalVariantStatus = (proposal: Proposal) => { useEffect(() => { if (!proposal || !proposal?.parameters || !totalSupply) return; - const minVetoVotingPower = (totalSupply * BigInt(proposal.parameters.minVetoVotingPower)) / BigInt(1_000_000); - setStatus( - proposal?.vetoTally >= minVetoVotingPower - ? { variant: "critical", label: "Defeated" } - : proposal?.active - ? { variant: "info", label: "Active" } - : proposal?.executed - ? { variant: "primary", label: "Executed" } - : { variant: "success", label: "Executable" } - ); + if (proposal?.active) { + setStatus({ variant: "info", label: "Active" }); + } else if (proposal?.executed) { + setStatus({ variant: "primary", label: "Executed" }); + } else if (proposal?.vetoTally >= minVetoVotingPower) { + setStatus({ variant: "critical", label: "Defeated" }); + } else { + setStatus({ variant: "success", label: "Executable" }); + } }, [ proposal?.vetoTally, proposal?.active, @@ -40,15 +39,15 @@ export const useProposalStatus = (proposal: Proposal) => { const minVetoVotingPower = (totalSupply * BigInt(proposal.parameters.minVetoVotingPower)) / BigInt(1_000_000); - setStatus( - proposal?.vetoTally >= minVetoVotingPower - ? ProposalStatus.VETOED - : proposal?.active - ? ProposalStatus.ACTIVE - : proposal?.executed - ? ProposalStatus.EXECUTED - : ProposalStatus.ACCEPTED - ); + if (proposal?.active) { + setStatus(ProposalStatus.ACTIVE); + } else if (proposal?.executed) { + setStatus(ProposalStatus.EXECUTED); + } else if (proposal?.vetoTally >= minVetoVotingPower) { + setStatus(ProposalStatus.VETOED); + } else { + setStatus(ProposalStatus.ACCEPTED); + } }, [ proposal?.vetoTally, proposal?.active, diff --git a/plugins/lockToVote/hooks/useProposalVetoes.ts b/plugins/lockToVote/hooks/useProposalVetoes.ts index 2d4981a..285324e 100644 --- a/plugins/lockToVote/hooks/useProposalVetoes.ts +++ b/plugins/lockToVote/hooks/useProposalVetoes.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { Address, getAbiItem } from "viem"; import { LockToVetoPluginAbi } from "../artifacts/LockToVetoPlugin.sol"; -import { Proposal, VetoCastEvent, VoteCastResponse } from "../utils/types"; +import { Proposal, VetoCastEvent } from "../utils/types"; import { usePublicClient } from "wagmi"; import { PUB_CHAIN } from "@/constants"; @@ -17,7 +17,7 @@ export function useProposalVetoes(pluginAddress: Address, proposalId: number, pr async function getLogs() { if (!publicClient || !proposal?.parameters?.snapshotBlock) return; - const logs: VoteCastResponse[] = (await publicClient.getLogs({ + const logs = await publicClient.getLogs({ address: pluginAddress, event, args: { @@ -25,7 +25,7 @@ export function useProposalVetoes(pluginAddress: Address, proposalId: number, pr }, fromBlock: proposal.parameters.snapshotBlock, toBlock: "latest", // TODO: Make this variable between 'latest' and proposal last block - })) as any; + }); const newLogs = logs.flatMap((log) => log.args); if (newLogs.length > proposalLogs.length) setLogs(newLogs); diff --git a/plugins/lockToVote/utils/types.tsx b/plugins/lockToVote/utils/types.tsx index 90da273..1a6ba26 100644 --- a/plugins/lockToVote/utils/types.tsx +++ b/plugins/lockToVote/utils/types.tsx @@ -31,7 +31,7 @@ export type VoteCastResponse = { }; export type VetoCastEvent = { - voter: Address; - proposalId: bigint; - votingPower: bigint; + voter?: Address; + proposalId?: bigint; + votingPower?: bigint; }; diff --git a/plugins/members/artifacts/MultisigPlugin.sol.ts b/plugins/members/artifacts/MultisigPlugin.sol.ts index 0bbb66a..59db05d 100644 --- a/plugins/members/artifacts/MultisigPlugin.sol.ts +++ b/plugins/members/artifacts/MultisigPlugin.sol.ts @@ -25,6 +25,14 @@ export const MultisigPluginAbi = [ name: "DaoUnauthorized", type: "error", }, + { + inputs: [ + { internalType: "uint64", name: "limit", type: "uint64" }, + { internalType: "uint64", name: "actual", type: "uint64" }, + ], + name: "DateOutOfBounds", + type: "error", + }, { inputs: [{ internalType: "address", name: "member", type: "address" }], name: "InvalidAddresslistUpdate", @@ -72,12 +80,6 @@ export const MultisigPluginAbi = [ name: "BeaconUpgraded", type: "event", }, - { - anonymous: false, - inputs: [{ indexed: true, internalType: "uint256", name: "proposalId", type: "uint256" }], - name: "Executed", - type: "event", - }, { anonymous: false, inputs: [{ indexed: false, internalType: "uint8", name: "version", type: "uint8" }], @@ -107,7 +109,6 @@ export const MultisigPluginAbi = [ inputs: [ { indexed: false, internalType: "bool", name: "onlyListed", type: "bool" }, { indexed: true, internalType: "uint16", name: "minApprovals", type: "uint16" }, - { indexed: false, internalType: "uint64", name: "destinationProposalDuration", type: "uint64" }, ], name: "MultisigSettingsUpdated", type: "event", @@ -220,11 +221,14 @@ export const MultisigPluginAbi = [ { internalType: "bytes", name: "data", type: "bytes" }, ], internalType: "struct IDAO.Action[]", - name: "_destinationActions", + name: "_actions", type: "tuple[]", }, - { internalType: "contract OptimisticTokenVotingPlugin", name: "_destinationPlugin", type: "address" }, + { internalType: "uint256", name: "_allowFailureMap", type: "uint256" }, { internalType: "bool", name: "_approveProposal", type: "bool" }, + { internalType: "bool", name: "_tryExecution", type: "bool" }, + { internalType: "uint64", name: "_startDate", type: "uint64" }, + { internalType: "uint64", name: "_endDate", type: "uint64" }, ], name: "createProposal", outputs: [{ internalType: "uint256", name: "proposalId", type: "uint256" }], @@ -255,13 +259,13 @@ export const MultisigPluginAbi = [ components: [ { internalType: "uint16", name: "minApprovals", type: "uint16" }, { internalType: "uint64", name: "snapshotBlock", type: "uint64" }, - { internalType: "uint64", name: "expirationDate", type: "uint64" }, + { internalType: "uint64", name: "startDate", type: "uint64" }, + { internalType: "uint64", name: "endDate", type: "uint64" }, ], internalType: "struct Multisig.ProposalParameters", name: "parameters", type: "tuple", }, - { internalType: "bytes", name: "metadataURI", type: "bytes" }, { components: [ { internalType: "address", name: "to", type: "address" }, @@ -269,10 +273,10 @@ export const MultisigPluginAbi = [ { internalType: "bytes", name: "data", type: "bytes" }, ], internalType: "struct IDAO.Action[]", - name: "destinationActions", + name: "actions", type: "tuple[]", }, - { internalType: "contract OptimisticTokenVotingPlugin", name: "destinationPlugin", type: "address" }, + { internalType: "uint256", name: "allowFailureMap", type: "uint256" }, ], stateMutability: "view", type: "function", @@ -302,7 +306,6 @@ export const MultisigPluginAbi = [ components: [ { internalType: "bool", name: "onlyListed", type: "bool" }, { internalType: "uint16", name: "minApprovals", type: "uint16" }, - { internalType: "uint64", name: "destinationProposalDuration", type: "uint64" }, ], internalType: "struct Multisig.MultisigSettings", name: "_multisigSettings", @@ -351,7 +354,6 @@ export const MultisigPluginAbi = [ outputs: [ { internalType: "bool", name: "onlyListed", type: "bool" }, { internalType: "uint16", name: "minApprovals", type: "uint16" }, - { internalType: "uint64", name: "destinationProposalDuration", type: "uint64" }, ], stateMutability: "view", type: "function", @@ -397,7 +399,6 @@ export const MultisigPluginAbi = [ components: [ { internalType: "bool", name: "onlyListed", type: "bool" }, { internalType: "uint16", name: "minApprovals", type: "uint16" }, - { internalType: "uint64", name: "destinationProposalDuration", type: "uint64" }, ], internalType: "struct Multisig.MultisigSettings", name: "_multisigSettings", diff --git a/plugins/multisig/artifacts/MultisigPlugin.sol.ts b/plugins/multisig/artifacts/MultisigPlugin.sol.ts new file mode 100644 index 0000000..59db05d --- /dev/null +++ b/plugins/multisig/artifacts/MultisigPlugin.sol.ts @@ -0,0 +1,430 @@ +export const MultisigPluginAbi = [ + { + inputs: [ + { internalType: "uint16", name: "limit", type: "uint16" }, + { internalType: "uint256", name: "actual", type: "uint256" }, + ], + name: "AddresslistLengthOutOfBounds", + type: "error", + }, + { + inputs: [ + { internalType: "uint256", name: "proposalId", type: "uint256" }, + { internalType: "address", name: "sender", type: "address" }, + ], + name: "ApprovalCastForbidden", + type: "error", + }, + { + inputs: [ + { internalType: "address", name: "dao", type: "address" }, + { internalType: "address", name: "where", type: "address" }, + { internalType: "address", name: "who", type: "address" }, + { internalType: "bytes32", name: "permissionId", type: "bytes32" }, + ], + name: "DaoUnauthorized", + type: "error", + }, + { + inputs: [ + { internalType: "uint64", name: "limit", type: "uint64" }, + { internalType: "uint64", name: "actual", type: "uint64" }, + ], + name: "DateOutOfBounds", + type: "error", + }, + { + inputs: [{ internalType: "address", name: "member", type: "address" }], + name: "InvalidAddresslistUpdate", + type: "error", + }, + { + inputs: [ + { internalType: "uint16", name: "limit", type: "uint16" }, + { internalType: "uint16", name: "actual", type: "uint16" }, + ], + name: "MinApprovalsOutOfBounds", + type: "error", + }, + { + inputs: [{ internalType: "address", name: "sender", type: "address" }], + name: "ProposalCreationForbidden", + type: "error", + }, + { + inputs: [{ internalType: "uint256", name: "proposalId", type: "uint256" }], + name: "ProposalExecutionForbidden", + type: "error", + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "address", name: "previousAdmin", type: "address" }, + { indexed: false, internalType: "address", name: "newAdmin", type: "address" }, + ], + name: "AdminChanged", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "uint256", name: "proposalId", type: "uint256" }, + { indexed: true, internalType: "address", name: "approver", type: "address" }, + ], + name: "Approved", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: "address", name: "beacon", type: "address" }], + name: "BeaconUpgraded", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "uint8", name: "version", type: "uint8" }], + name: "Initialized", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "address[]", name: "members", type: "address[]" }], + name: "MembersAdded", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: false, internalType: "address[]", name: "members", type: "address[]" }], + name: "MembersRemoved", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: "address", name: "definingContract", type: "address" }], + name: "MembershipContractAnnounced", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: false, internalType: "bool", name: "onlyListed", type: "bool" }, + { indexed: true, internalType: "uint16", name: "minApprovals", type: "uint16" }, + ], + name: "MultisigSettingsUpdated", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "uint256", name: "proposalId", type: "uint256" }, + { indexed: true, internalType: "address", name: "creator", type: "address" }, + { indexed: false, internalType: "uint64", name: "startDate", type: "uint64" }, + { indexed: false, internalType: "uint64", name: "endDate", type: "uint64" }, + { indexed: false, internalType: "bytes", name: "metadata", type: "bytes" }, + { + components: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + indexed: false, + internalType: "struct IDAO.Action[]", + name: "actions", + type: "tuple[]", + }, + { indexed: false, internalType: "uint256", name: "allowFailureMap", type: "uint256" }, + ], + name: "ProposalCreated", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: "uint256", name: "proposalId", type: "uint256" }], + name: "ProposalExecuted", + type: "event", + }, + { + anonymous: false, + inputs: [{ indexed: true, internalType: "address", name: "implementation", type: "address" }], + name: "Upgraded", + type: "event", + }, + { + inputs: [], + name: "UPDATE_MULTISIG_SETTINGS_PERMISSION_ID", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "UPGRADE_PLUGIN_PERMISSION_ID", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address[]", name: "_members", type: "address[]" }], + name: "addAddresses", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "addresslistLength", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_blockNumber", type: "uint256" }], + name: "addresslistLengthAtBlock", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_proposalId", type: "uint256" }, + { internalType: "bool", name: "_tryExecution", type: "bool" }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_proposalId", type: "uint256" }, + { internalType: "address", name: "_account", type: "address" }, + ], + name: "canApprove", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_proposalId", type: "uint256" }], + name: "canExecute", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "bytes", name: "_metadataURI", type: "bytes" }, + { + components: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + internalType: "struct IDAO.Action[]", + name: "_actions", + type: "tuple[]", + }, + { internalType: "uint256", name: "_allowFailureMap", type: "uint256" }, + { internalType: "bool", name: "_approveProposal", type: "bool" }, + { internalType: "bool", name: "_tryExecution", type: "bool" }, + { internalType: "uint64", name: "_startDate", type: "uint64" }, + { internalType: "uint64", name: "_endDate", type: "uint64" }, + ], + name: "createProposal", + outputs: [{ internalType: "uint256", name: "proposalId", type: "uint256" }], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [], + name: "dao", + outputs: [{ internalType: "contract IDAO", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_proposalId", type: "uint256" }], + name: "execute", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "_proposalId", type: "uint256" }], + name: "getProposal", + outputs: [ + { internalType: "bool", name: "executed", type: "bool" }, + { internalType: "uint16", name: "approvals", type: "uint16" }, + { + components: [ + { internalType: "uint16", name: "minApprovals", type: "uint16" }, + { internalType: "uint64", name: "snapshotBlock", type: "uint64" }, + { internalType: "uint64", name: "startDate", type: "uint64" }, + { internalType: "uint64", name: "endDate", type: "uint64" }, + ], + internalType: "struct Multisig.ProposalParameters", + name: "parameters", + type: "tuple", + }, + { + components: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "value", type: "uint256" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + internalType: "struct IDAO.Action[]", + name: "actions", + type: "tuple[]", + }, + { internalType: "uint256", name: "allowFailureMap", type: "uint256" }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "uint256", name: "_proposalId", type: "uint256" }, + { internalType: "address", name: "_account", type: "address" }, + ], + name: "hasApproved", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "implementation", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "contract IDAO", name: "_dao", type: "address" }, + { internalType: "address[]", name: "_members", type: "address[]" }, + { + components: [ + { internalType: "bool", name: "onlyListed", type: "bool" }, + { internalType: "uint16", name: "minApprovals", type: "uint16" }, + ], + internalType: "struct Multisig.MultisigSettings", + name: "_multisigSettings", + type: "tuple", + }, + ], + name: "initialize", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_account", type: "address" }], + name: "isListed", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "_account", type: "address" }, + { internalType: "uint256", name: "_blockNumber", type: "uint256" }, + ], + name: "isListedAtBlock", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "_account", type: "address" }], + name: "isMember", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "lastMultisigSettingsChange", + outputs: [{ internalType: "uint64", name: "", type: "uint64" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "multisigSettings", + outputs: [ + { internalType: "bool", name: "onlyListed", type: "bool" }, + { internalType: "uint16", name: "minApprovals", type: "uint16" }, + ], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "pluginType", + outputs: [{ internalType: "enum IPlugin.PluginType", name: "", type: "uint8" }], + stateMutability: "pure", + type: "function", + }, + { + inputs: [], + name: "proposalCount", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "proxiableUUID", + outputs: [{ internalType: "bytes32", name: "", type: "bytes32" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address[]", name: "_members", type: "address[]" }], + name: "removeAddresses", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "bytes4", name: "_interfaceId", type: "bytes4" }], + name: "supportsInterface", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { + components: [ + { internalType: "bool", name: "onlyListed", type: "bool" }, + { internalType: "uint16", name: "minApprovals", type: "uint16" }, + ], + internalType: "struct Multisig.MultisigSettings", + name: "_multisigSettings", + type: "tuple", + }, + ], + name: "updateMultisigSettings", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "newImplementation", type: "address" }], + name: "upgradeTo", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "newImplementation", type: "address" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + name: "upgradeToAndCall", + outputs: [], + stateMutability: "payable", + type: "function", + }, +] as const; diff --git a/plugins/multisig/components/proposal/header.tsx b/plugins/multisig/components/proposal/header.tsx index 5af5c17..89e352f 100644 --- a/plugins/multisig/components/proposal/header.tsx +++ b/plugins/multisig/components/proposal/header.tsx @@ -18,7 +18,7 @@ const ProposalHeader: React.FC = ({ proposalId, proposal }) const proposalStatus = useProposalStatus(proposal); const tagVariant = getTagVariantFromStatus(proposalStatus); const breadcrumbs: IBreadcrumbsLink[] = [{ label: "Proposals", href: "#/" }, { label: proposalId.toString() }]; - const expired = Number(proposal.parameters.expirationDate) * 1000 <= Date.now(); + const expired = Number(proposal.parameters.endDate) * 1000 <= Date.now(); return (
@@ -59,7 +59,7 @@ const ProposalHeader: React.FC = ({ proposalId, proposal }) - {getSimpleRelativeTimeFromDate(dayjs(Number(proposal.parameters.expirationDate) * 1000))} + {getSimpleRelativeTimeFromDate(dayjs(Number(proposal.parameters.endDate) * 1000))} left until expiration diff --git a/plugins/multisig/components/proposal/index.tsx b/plugins/multisig/components/proposal/index.tsx index 0eba0b2..1518dad 100644 --- a/plugins/multisig/components/proposal/index.tsx +++ b/plugins/multisig/components/proposal/index.tsx @@ -64,8 +64,8 @@ export default function ProposalCard(props: ProposalInputs) { href={`#/proposals/${props.proposalId}`} voted={hasApproved} date={ - [ProposalStatus.ACTIVE, ProposalStatus.ACCEPTED].includes(proposalStatus!) && proposal.parameters.expirationDate - ? Number(proposal.parameters.expirationDate) * 1000 + [ProposalStatus.ACTIVE, ProposalStatus.ACCEPTED].includes(proposalStatus!) && proposal.parameters.endDate + ? Number(proposal.parameters.endDate) * 1000 : undefined } result={{ diff --git a/plugins/multisig/hooks/useCanCreateProposal.ts b/plugins/multisig/hooks/useCanCreateProposal.ts index fdcfc79..f5cebdf 100644 --- a/plugins/multisig/hooks/useCanCreateProposal.ts +++ b/plugins/multisig/hooks/useCanCreateProposal.ts @@ -1,6 +1,6 @@ import { useAccount, useReadContract } from "wagmi"; import { PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; export function useCanCreateProposal() { const { address } = useAccount(); diff --git a/plugins/multisig/hooks/useCreateProposal.ts b/plugins/multisig/hooks/useCreateProposal.ts index e4ee264..ea19907 100644 --- a/plugins/multisig/hooks/useCreateProposal.ts +++ b/plugins/multisig/hooks/useCreateProposal.ts @@ -1,23 +1,23 @@ import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ProposalMetadata, RawAction } from "@/utils/types"; -import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { useAlerts } from "@/context/Alerts"; -import { - PUB_APP_NAME, - PUB_CHAIN, - PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, - PUB_MULTISIG_PLUGIN_ADDRESS, - PUB_PROJECT_URL, -} from "@/constants"; +import { PUB_APP_NAME, PUB_CHAIN, PUB_MULTISIG_PLUGIN_ADDRESS, PUB_PROJECT_URL } from "@/constants"; import { uploadToPinata } from "@/utils/ipfs"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; import { URL_PATTERN } from "@/utils/input-values"; import { toHex } from "viem"; import { useTransactionManager } from "@/hooks/useTransactionManager"; +const PROPOSAL_EXPIRATION_TIME = 60 * 60 * 24 * 10; // 10 days in seconds const UrlRegex = new RegExp(URL_PATTERN); +type CreateProposalParams = { + allowFailureMap?: number; + approveProposal?: boolean; + tryExecution?: boolean; +}; + export function useCreateProposal() { const { push } = useRouter(); const { addAlert } = useAlerts(); @@ -30,7 +30,11 @@ export function useCreateProposal() { { name: PUB_APP_NAME, url: PUB_PROJECT_URL }, ]); - const { writeContract: createProposalWrite, isConfirming } = useTransactionManager({ + const { + writeContract: createProposalWrite, + isConfirming, + status, + } = useTransactionManager({ onSuccessMessage: "Proposal created", onSuccess() { setTimeout(() => { @@ -42,7 +46,7 @@ export function useCreateProposal() { onError: () => setIsCreating(false), }); - const submitProposal = async () => { + const submitProposal = async ({ allowFailureMap, approveProposal, tryExecution }: CreateProposalParams = {}) => { // Check metadata if (!title.trim()) { return addAlert("Invalid proposal details", { @@ -72,6 +76,10 @@ export function useCreateProposal() { } } + if (typeof allowFailureMap === "number" && (allowFailureMap >= 256 || allowFailureMap < 0)) { + return addAlert("Internal error", { description: "Received an invalid proposal parameter", type: "error" }); + } + try { setIsCreating(true); const proposalMetadataJsonObject: ProposalMetadata = { @@ -82,13 +90,22 @@ export function useCreateProposal() { }; const ipfsPin = await uploadToPinata(JSON.stringify(proposalMetadataJsonObject)); + const endDate = Math.floor(Date.now() / 1000) + PROPOSAL_EXPIRATION_TIME; createProposalWrite({ chainId: PUB_CHAIN.id, abi: MultisigPluginAbi, address: PUB_MULTISIG_PLUGIN_ADDRESS, functionName: "createProposal", - args: [toHex(ipfsPin), actions, PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, false], + args: [ + toHex(ipfsPin), + actions, + BigInt(allowFailureMap || 0), + approveProposal ?? false, + tryExecution ?? false, + BigInt(0), // startDate: now + BigInt(endDate), // endDate: now + 10 days + ], }); } catch (err) { setIsCreating(false); diff --git a/plugins/multisig/hooks/useProposal.ts b/plugins/multisig/hooks/useProposal.ts index d379b35..33ce41b 100644 --- a/plugins/multisig/hooks/useProposal.ts +++ b/plugins/multisig/hooks/useProposal.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useBlockNumber, usePublicClient, useReadContract } from "wagmi"; import { getAbiItem } from "viem"; -import { MultisigPluginAbi } from "@/plugins/multisig/artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "@/plugins/multisig/artifacts/MultisigPlugin.sol"; import { RawAction, ProposalMetadata } from "@/utils/types"; import { MultisigProposal, @@ -10,6 +10,7 @@ import { } from "@/plugins/multisig/utils/types"; import { PUB_CHAIN, PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; import { useMetadata } from "@/hooks/useMetadata"; +import { useProposalCreatedLogs } from "./useProposalCreatedLogs"; const ProposalCreatedEvent = getAbiItem({ abi: MultisigPluginAbi, @@ -46,6 +47,7 @@ export function useProposal(proposalId: string, autoRefresh = false) { args: [BigInt(proposalId)], chainId: PUB_CHAIN.id, }); + const creationEvent = useProposalCreatedLogs(proposalId); const proposalData = decodeProposalResultData(proposalResult); @@ -58,7 +60,7 @@ export function useProposal(proposalId: string, autoRefresh = false) { data: metadataContent, isLoading: metadataLoading, error: metadataError, - } = useMetadata(proposalData?.metadataUri); + } = useMetadata(creationEvent?.metadata); const proposal = arrangeProposalData(proposalData, proposalCreationEvent, metadataContent); @@ -108,8 +110,8 @@ function decodeProposalResultData(data?: MultisigProposalResultType) { executed: data[0] as boolean, approvals: data[1] as number, parameters: data[2] as MultisigProposalParameters, - metadataUri: data[3] as string, - actions: data[4] as Array, + actions: data[3] as Array, + allowFailureMap: data[4], }; } @@ -124,9 +126,10 @@ function arrangeProposalData( actions: proposalData.actions, executed: proposalData.executed, parameters: { - expirationDate: proposalData.parameters.expirationDate, snapshotBlock: proposalData.parameters.snapshotBlock, minApprovals: proposalData.parameters.minApprovals, + startDate: proposalData.parameters.startDate, + endDate: proposalData.parameters.endDate, }, approvals: proposalData.approvals, allowFailureMap: BigInt(0), diff --git a/plugins/multisig/hooks/useProposalApprovals.ts b/plugins/multisig/hooks/useProposalApprovals.ts index 83a97d2..4511607 100644 --- a/plugins/multisig/hooks/useProposalApprovals.ts +++ b/plugins/multisig/hooks/useProposalApprovals.ts @@ -1,8 +1,8 @@ import { useState, useEffect } from "react"; import { Address, getAbiItem } from "viem"; import { usePublicClient } from "wagmi"; -import { MultisigProposal, ApprovedEvent, ApprovedEventResponse } from "../utils/types"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigProposal, ApprovedEvent } from "../utils/types"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; import { PUB_CHAIN } from "@/constants"; const event = getAbiItem({ @@ -17,7 +17,7 @@ export function useProposalApprovals(pluginAddress: Address, proposalId: string, async function getLogs() { if (!publicClient || !proposal?.parameters?.snapshotBlock) return; - const logs: ApprovedEventResponse[] = (await publicClient.getLogs({ + const logs = await publicClient.getLogs({ address: pluginAddress, event: event, args: { @@ -25,7 +25,7 @@ export function useProposalApprovals(pluginAddress: Address, proposalId: string, }, fromBlock: proposal.parameters.snapshotBlock, toBlock: "latest", // TODO: Make this variable between 'latest' and proposal last block - })) as any; + }); const newLogs = logs.flatMap((log) => log.args); if (newLogs.length > proposalLogs.length) setLogs(newLogs); diff --git a/plugins/multisig/hooks/useProposalApprove.ts b/plugins/multisig/hooks/useProposalApprove.ts index 6df6e75..b5d72c7 100644 --- a/plugins/multisig/hooks/useProposalApprove.ts +++ b/plugins/multisig/hooks/useProposalApprove.ts @@ -1,6 +1,6 @@ import { useProposal } from "./useProposal"; import { useUserCanApprove } from "./useUserCanApprove"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; import { PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; import { useProposalApprovals } from "./useProposalApprovals"; import { useRouter } from "next/router"; diff --git a/plugins/multisig/hooks/useProposalCreatedLogs.ts b/plugins/multisig/hooks/useProposalCreatedLogs.ts new file mode 100644 index 0000000..bb9b08e --- /dev/null +++ b/plugins/multisig/hooks/useProposalCreatedLogs.ts @@ -0,0 +1,50 @@ +import { useState, useEffect } from "react"; +import { Address, Hex, getAbiItem } from "viem"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; +import { usePublicClient } from "wagmi"; +import { PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; + +type ProposalCreatedEventArguments = { + proposalId?: bigint | undefined; + creator?: Address | undefined; + startDate?: bigint | undefined; + endDate?: bigint | undefined; + metadata?: Hex | undefined; + actions?: + | readonly { + to: Address; + value: bigint; + data: Hex; + }[] + | undefined; + allowFailureMap?: bigint | undefined; +}; + +const event = getAbiItem({ abi: MultisigPluginAbi, name: "ProposalCreated" }); + +export function useProposalCreatedLogs(proposalId: string) { + const publicClient = usePublicClient(); + const [creationLog, setCreationLog] = useState(); + + useEffect(() => { + if (!publicClient || proposalId === undefined) return; + + publicClient + .getLogs({ + address: PUB_MULTISIG_PLUGIN_ADDRESS, + event, + args: { + proposalId: BigInt(proposalId), + }, + fromBlock: BigInt(0), + toBlock: "latest", + }) + .then((logs) => { + if (!logs.length) return; + + setCreationLog(logs[0].args); + }); + }, [proposalId, !!publicClient]); + + return creationLog; +} diff --git a/plugins/multisig/hooks/useProposalExecute.ts b/plugins/multisig/hooks/useProposalExecute.ts index 0c9da3c..3d851f1 100644 --- a/plugins/multisig/hooks/useProposalExecute.ts +++ b/plugins/multisig/hooks/useProposalExecute.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { useReadContract } from "wagmi"; import { useRouter } from "next/router"; import { PUB_CHAIN, PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; import { useTransactionManager } from "@/hooks/useTransactionManager"; export function useProposalExecute(proposalId: string) { diff --git a/plugins/multisig/hooks/useProposalVariantStatus.ts b/plugins/multisig/hooks/useProposalVariantStatus.ts index 33a8be6..11f2c0f 100644 --- a/plugins/multisig/hooks/useProposalVariantStatus.ts +++ b/plugins/multisig/hooks/useProposalVariantStatus.ts @@ -1,22 +1,26 @@ import { useState, useEffect } from "react"; import { MultisigProposal } from "@/plugins/multisig/utils/types"; import { ProposalStatus } from "@aragon/ods"; -import dayjs from "dayjs"; export const useProposalVariantStatus = (proposal: MultisigProposal) => { const [status, setStatus] = useState({ variant: "", label: "" }); useEffect(() => { if (!proposal || !proposal?.parameters) return; - setStatus( - proposal?.approvals >= proposal?.parameters?.minApprovals - ? proposal?.executed - ? { variant: "success", label: "Sent to optimistic approval" } - : { variant: "success", label: "Executable" } - : dayjs().isAfter(dayjs(Number(proposal?.parameters.expirationDate) * 1000)) - ? { variant: "critical", label: "Failed" } - : { variant: "info", label: "Active" } - ); + + if (proposal?.executed) { + setStatus({ variant: "primary", label: "Executed" }); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.endDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus({ variant: "critical", label: "Defeated" }); + } else { + setStatus({ variant: "critical", label: "Expired" }); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus({ variant: "success", label: "Executable" }); + } else { + setStatus({ variant: "info", label: "Active" }); + } }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); return status; @@ -27,15 +31,20 @@ export const useProposalStatus = (proposal: MultisigProposal) => { useEffect(() => { if (!proposal || !proposal?.parameters) return; - setStatus( - proposal?.approvals >= proposal?.parameters?.minApprovals - ? proposal?.executed - ? ProposalStatus.EXECUTED - : ProposalStatus.ACCEPTED - : dayjs().isAfter(dayjs(Number(proposal?.parameters.expirationDate) * 1000)) - ? ProposalStatus.FAILED - : ProposalStatus.ACTIVE - ); + + if (proposal?.executed) { + setStatus(ProposalStatus.EXECUTED); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.endDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus(ProposalStatus.REJECTED); + } else { + setStatus(ProposalStatus.EXPIRED); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus(ProposalStatus.EXECUTABLE); + } else { + setStatus(ProposalStatus.ACTIVE); + } }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); return status; diff --git a/plugins/multisig/hooks/useUserCanApprove.ts b/plugins/multisig/hooks/useUserCanApprove.ts index 4b2adef..3856291 100644 --- a/plugins/multisig/hooks/useUserCanApprove.ts +++ b/plugins/multisig/hooks/useUserCanApprove.ts @@ -1,7 +1,7 @@ import { useAccount, useBlockNumber, useReadContract } from "wagmi"; import { useEffect } from "react"; import { PUB_CHAIN, PUB_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; -import { MultisigPluginAbi } from "../artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "../artifacts/MultisigPlugin.sol"; export function useUserCanApprove(proposalId: string | bigint | number) { const { address } = useAccount(); diff --git a/plugins/multisig/pages/proposal-list.tsx b/plugins/multisig/pages/proposal-list.tsx index 745b920..473495d 100644 --- a/plugins/multisig/pages/proposal-list.tsx +++ b/plugins/multisig/pages/proposal-list.tsx @@ -1,7 +1,7 @@ import { useAccount, useBlockNumber, useReadContract } from "wagmi"; import { type ReactNode, useEffect } from "react"; import ProposalCard from "@/plugins/multisig/components/proposal"; -import { MultisigPluginAbi } from "@/plugins/multisig/artifacts/MultisigPlugin"; +import { MultisigPluginAbi } from "@/plugins/multisig/artifacts/MultisigPlugin.sol"; import { Button, DataList, IconType, ProposalDataListItemSkeleton, type DataListState } from "@aragon/ods"; import { useCanCreateProposal } from "@/plugins/multisig/hooks/useCanCreateProposal"; import Link from "next/link"; diff --git a/plugins/multisig/pages/proposal.tsx b/plugins/multisig/pages/proposal.tsx index f4425b8..a00eb04 100644 --- a/plugins/multisig/pages/proposal.tsx +++ b/plugins/multisig/pages/proposal.tsx @@ -60,7 +60,7 @@ export default function ProposalDetail({ id: proposalId }: { id: string }) { details: { censusBlock: Number(proposal?.parameters.snapshotBlock), startDate: "", - endDate: dayjs(Number(proposal?.parameters.expirationDate) * 1000).toString(), + endDate: dayjs(Number(proposal?.parameters.endDate) * 1000).toString(), strategy: "Approval threshold", options: "Approve", }, @@ -86,7 +86,7 @@ export default function ProposalDetail({ id: proposalId }: { id: string }) {
diff --git a/plugins/multisig/utils/types.ts b/plugins/multisig/utils/types.ts index a2e5c78..a062495 100644 --- a/plugins/multisig/utils/types.ts +++ b/plugins/multisig/utils/types.ts @@ -6,18 +6,18 @@ export type ProposalInputs = { }; export type MultisigProposalResultType = readonly [ - boolean, - number, - MultisigProposalParameters, - string, - readonly RawAction[], - Address, + boolean, // executed + number, // approvals + MultisigProposalParameters, // proposalParameters + readonly RawAction[], // actions + bigint, // allowFailureMap ]; export type MultisigProposalParameters = { - expirationDate: bigint; - snapshotBlock: bigint; minApprovals: number; + snapshotBlock: bigint; + startDate: bigint; + endDate: bigint; }; export type MultisigProposal = { @@ -33,11 +33,7 @@ export type MultisigProposal = { resources: IProposalResource[]; }; -export type ApprovedEventResponse = { - args: ApprovedEvent[]; -}; - export type ApprovedEvent = { - proposalId: bigint; - approver: Address; + proposalId?: bigint; + approver?: Address; }; diff --git a/plugins/multisig/artifacts/MultisigPlugin.tsx b/plugins/opt-multisig/artifacts/OptimisticMultisigPlugin.tsx similarity index 99% rename from plugins/multisig/artifacts/MultisigPlugin.tsx rename to plugins/opt-multisig/artifacts/OptimisticMultisigPlugin.tsx index 0bbb66a..a4a8423 100644 --- a/plugins/multisig/artifacts/MultisigPlugin.tsx +++ b/plugins/opt-multisig/artifacts/OptimisticMultisigPlugin.tsx @@ -1,4 +1,4 @@ -export const MultisigPluginAbi = [ +export const OptimisticMultisigPluginAbi = [ { inputs: [ { internalType: "uint16", name: "limit", type: "uint16" }, diff --git a/plugins/opt-multisig/components/proposal/header.tsx b/plugins/opt-multisig/components/proposal/header.tsx new file mode 100644 index 0000000..9cce525 --- /dev/null +++ b/plugins/opt-multisig/components/proposal/header.tsx @@ -0,0 +1,75 @@ +import { AvatarIcon, Breadcrumbs, Heading, IBreadcrumbsLink, IconType, ProposalStatus, TagVariant } from "@aragon/ods"; +import { MultisigProposal } from "@/plugins/opt-multisig/utils/types"; +import { useProposalStatus } from "@/plugins/opt-multisig/hooks/useProposalVariantStatus"; +import dayjs from "dayjs"; +import { HeaderSection } from "@/components/layout/header-section"; +import { Publisher } from "@/components/publisher"; +import { getSimpleRelativeTimeFromDate } from "@/utils/dates"; +import { Else, ElseIf, If, Then } from "@/components/if"; +import { getTagVariantFromStatus } from "@/utils/ui-variants"; +import { capitalizeFirstLetter } from "@/utils/text"; + +interface ProposalHeaderProps { + proposalId: string; + proposal: MultisigProposal; +} + +const ProposalHeader: React.FC = ({ proposalId, proposal }) => { + const proposalStatus = useProposalStatus(proposal); + const tagVariant = getTagVariantFromStatus(proposalStatus); + const breadcrumbs: IBreadcrumbsLink[] = [{ label: "Proposals", href: "#/" }, { label: proposalId.toString() }]; + const expired = Number(proposal.parameters.expirationDate) * 1000 <= Date.now(); + + return ( +
+ {/* Wrapper */} + + + {/* Title & description */} +
+
+ {proposal.title} + {/* && */} +
+

{proposal.summary}

+
+ {/* Metadata */} +
+
+ + +
+
+ +
+ + + The proposal was sent to the community stage + + + The proposal expired + + + + {getSimpleRelativeTimeFromDate(dayjs(Number(proposal.parameters.expirationDate) * 1000))} + + left until expiration + + +
+
+
+
+
+ ); +}; + +export default ProposalHeader; diff --git a/plugins/opt-multisig/components/proposal/index.tsx b/plugins/opt-multisig/components/proposal/index.tsx new file mode 100644 index 0000000..b3409c2 --- /dev/null +++ b/plugins/opt-multisig/components/proposal/index.tsx @@ -0,0 +1,91 @@ +import Link from "next/link"; +import { useProposalApprove } from "@/plugins/opt-multisig/hooks/useProposalApprove"; +import { Card, ProposalStatus } from "@aragon/ods"; +import { ProposalDataListItem } from "@aragon/ods"; +import { PleaseWaitSpinner } from "@/components/please-wait"; +import { useProposalStatus } from "../../hooks/useProposalVariantStatus"; +import { useAccount } from "wagmi"; + +const DEFAULT_PROPOSAL_METADATA_TITLE = "(No proposal title)"; +const DEFAULT_PROPOSAL_METADATA_SUMMARY = "(The metadata of the proposal is not available)"; + +type ProposalInputs = { + proposalId: bigint; +}; + +export default function ProposalCard(props: ProposalInputs) { + const { address } = useAccount(); + const { proposal, proposalFetchStatus, approvals } = useProposalApprove(props.proposalId.toString()); + + const proposalStatus = useProposalStatus(proposal!); + const showLoading = getShowProposalLoading(proposal, proposalFetchStatus); + const hasApproved = approvals?.some((veto) => veto.approver === address); + + if (!proposal && showLoading) { + return ( +
+ + + + + +
+ ); + } else if (!proposal?.title && !proposal?.summary) { + // We have the proposal but no metadata yet + return ( + + + + + + + + ); + } else if (proposalFetchStatus.metadataReady && !proposal?.title) { + return ( + + +
+

+ {Number(props.proposalId) + 1} - {DEFAULT_PROPOSAL_METADATA_TITLE} +

+

{DEFAULT_PROPOSAL_METADATA_SUMMARY}

+
+
+ + ); + } + + return ( + + ); +} + +function getShowProposalLoading( + proposal: ReturnType["proposal"], + status: ReturnType["proposalFetchStatus"] +) { + if (!proposal || status.proposalLoading) return true; + else if (status.metadataLoading && !status.metadataError) return true; + else if (!proposal?.title && !status.metadataError) return true; + + return false; +} diff --git a/plugins/opt-multisig/hooks/useCanCreateProposal.ts b/plugins/opt-multisig/hooks/useCanCreateProposal.ts new file mode 100644 index 0000000..0c974fb --- /dev/null +++ b/plugins/opt-multisig/hooks/useCanCreateProposal.ts @@ -0,0 +1,29 @@ +import { useAccount, useReadContract } from "wagmi"; +import { PUB_OPT_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; + +export function useCanCreateProposal() { + const { address } = useAccount(); + const { + data: canCreate, + isLoading, + error, + refetch, + } = useReadContract({ + abi: OptimisticMultisigPluginAbi, + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + functionName: "isMember", + args: [address!], + + query: { + retry: true, + refetchOnMount: false, + refetchOnReconnect: false, + retryOnMount: true, + enabled: !!address, + staleTime: 1000 * 60 * 5, + }, + }); + + return { canCreate, isLoading, error, refetch }; +} diff --git a/plugins/opt-multisig/hooks/useCreateProposal.ts b/plugins/opt-multisig/hooks/useCreateProposal.ts new file mode 100644 index 0000000..f7b703c --- /dev/null +++ b/plugins/opt-multisig/hooks/useCreateProposal.ts @@ -0,0 +1,112 @@ +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { ProposalMetadata, RawAction } from "@/utils/types"; +import { useWaitForTransactionReceipt, useWriteContract } from "wagmi"; +import { useAlerts } from "@/context/Alerts"; +import { + PUB_APP_NAME, + PUB_CHAIN, + PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, + PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + PUB_PROJECT_URL, +} from "@/constants"; +import { uploadToPinata } from "@/utils/ipfs"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; +import { URL_PATTERN } from "@/utils/input-values"; +import { toHex } from "viem"; +import { useTransactionManager } from "@/hooks/useTransactionManager"; + +const UrlRegex = new RegExp(URL_PATTERN); + +export function useCreateProposal() { + const { push } = useRouter(); + const { addAlert } = useAlerts(); + const [isCreating, setIsCreating] = useState(false); + const [title, setTitle] = useState(""); + const [summary, setSummary] = useState(""); + const [description, setDescription] = useState(""); + const [actions, setActions] = useState([]); + const [resources, setResources] = useState<{ name: string; url: string }[]>([ + { name: PUB_APP_NAME, url: PUB_PROJECT_URL }, + ]); + + const { writeContract: createProposalWrite, isConfirming } = useTransactionManager({ + onSuccessMessage: "Proposal created", + onSuccess() { + setTimeout(() => { + push("#/"); + window.scroll(0, 0); + }, 1000 * 2); + }, + onErrorMessage: "Could not create the proposal", + onError: () => setIsCreating(false), + }); + + const submitProposal = async () => { + // Check metadata + if (!title.trim()) { + return addAlert("Invalid proposal details", { + description: "Please enter a title", + type: "error", + }); + } + + if (!summary.trim()) { + return addAlert("Invalid proposal details", { + description: "Please enter a summary of what the proposal is about", + type: "error", + }); + } + + for (const item of resources) { + if (!item.name.trim()) { + return addAlert("Invalid resource name", { + description: "Please enter a name for all the resources", + type: "error", + }); + } else if (!UrlRegex.test(item.url.trim())) { + return addAlert("Invalid resource URL", { + description: "Please enter valid URL for all the resources", + type: "error", + }); + } + } + + try { + setIsCreating(true); + const proposalMetadataJsonObject: ProposalMetadata = { + title, + summary, + description, + resources, + }; + + const ipfsPin = await uploadToPinata(JSON.stringify(proposalMetadataJsonObject)); + + createProposalWrite({ + chainId: PUB_CHAIN.id, + abi: OptimisticMultisigPluginAbi, + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + functionName: "createProposal", + args: [toHex(ipfsPin), actions, PUB_DUAL_GOVERNANCE_PLUGIN_ADDRESS, false], + }); + } catch (err) { + setIsCreating(false); + } + }; + + return { + isCreating: isCreating || isConfirming || status === "pending", + title, + summary, + description, + actions, + resources, + setTitle, + setSummary, + setDescription, + setActions, + setResources, + submitProposal, + }; +} diff --git a/plugins/opt-multisig/hooks/useProposal.ts b/plugins/opt-multisig/hooks/useProposal.ts new file mode 100644 index 0000000..94a91f4 --- /dev/null +++ b/plugins/opt-multisig/hooks/useProposal.ts @@ -0,0 +1,139 @@ +import { useState, useEffect } from "react"; +import { useBlockNumber, usePublicClient, useReadContract } from "wagmi"; +import { getAbiItem } from "viem"; +import { OptimisticMultisigPluginAbi } from "@/plugins/opt-multisig/artifacts/OptimisticMultisigPlugin"; +import { RawAction, ProposalMetadata } from "@/utils/types"; +import { + MultisigProposal, + MultisigProposalParameters, + MultisigProposalResultType, +} from "@/plugins/opt-multisig/utils/types"; +import { PUB_CHAIN, PUB_OPT_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; +import { useMetadata } from "@/hooks/useMetadata"; + +const ProposalCreatedEvent = getAbiItem({ + abi: OptimisticMultisigPluginAbi, + name: "ProposalCreated", +}); + +type ProposalCreatedLogResponse = { + args: { + actions: RawAction[]; + allowFailureMap: bigint; + creator: string; + endDate: bigint; + startDate: bigint; + metadata: string; + proposalId: bigint; + }; +}; + +export function useProposal(proposalId: string, autoRefresh = false) { + const publicClient = usePublicClient(); + const [proposalCreationEvent, setProposalCreationEvent] = useState(); + const { data: blockNumber } = useBlockNumber({ watch: true }); + + // Proposal onchain data + const { + data: proposalResult, + error: proposalError, + fetchStatus: proposalFetchStatus, + refetch: proposalRefetch, + } = useReadContract({ + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + abi: OptimisticMultisigPluginAbi, + functionName: "getProposal", + args: [BigInt(proposalId)], + chainId: PUB_CHAIN.id, + }); + + const proposalData = decodeProposalResultData(proposalResult); + + useEffect(() => { + if (autoRefresh) proposalRefetch(); + }, [blockNumber]); + + // JSON metadata + const { + data: metadataContent, + isLoading: metadataLoading, + error: metadataError, + } = useMetadata(proposalData?.metadataUri); + + const proposal = arrangeProposalData(proposalData, proposalCreationEvent, metadataContent); + + useEffect(() => { + if (!proposalData || !publicClient || proposalCreationEvent) return; + + publicClient + .getLogs({ + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + event: ProposalCreatedEvent, + args: { proposalId: BigInt(proposalId) }, + fromBlock: proposalData.parameters.snapshotBlock, + toBlock: "latest", + }) + .then((logs) => { + if (!logs || !logs.length) throw new Error("No creation logs"); + + const log: ProposalCreatedLogResponse = logs[0] as any; + setProposalCreationEvent(log.args); + }) + .catch((err) => { + console.error("Could not fetch the proposal details", err); + return null; + }); + }, [proposalData, !!publicClient]); + + return { + proposal, + refetch: proposalRefetch, + status: { + proposalReady: proposalFetchStatus === "idle", + proposalLoading: proposalFetchStatus === "fetching", + proposalError, + metadataReady: !metadataError && !metadataLoading && !!metadataContent, + metadataLoading, + metadataError: metadataError !== undefined, + }, + }; +} + +// Helpers + +function decodeProposalResultData(data?: MultisigProposalResultType) { + if (!data?.length) return null; + + return { + executed: data[0] as boolean, + approvals: data[1] as number, + parameters: data[2] as MultisigProposalParameters, + metadataUri: data[3] as string, + actions: data[4] as Array, + }; +} + +function arrangeProposalData( + proposalData?: ReturnType, + creationEvent?: ProposalCreatedLogResponse["args"], + metadata?: ProposalMetadata +): MultisigProposal | null { + if (!proposalData) return null; + + return { + actions: proposalData.actions, + executed: proposalData.executed, + parameters: { + expirationDate: proposalData.parameters.expirationDate, + snapshotBlock: proposalData.parameters.snapshotBlock, + minApprovals: proposalData.parameters.minApprovals, + }, + approvals: proposalData.approvals, + allowFailureMap: BigInt(0), + creator: creationEvent?.creator || "", + title: metadata?.title || "", + summary: metadata?.summary || "", + description: metadata?.description || "", + resources: metadata?.resources || [], + }; +} diff --git a/plugins/opt-multisig/hooks/useProposalApprovals.ts b/plugins/opt-multisig/hooks/useProposalApprovals.ts new file mode 100644 index 0000000..cb64525 --- /dev/null +++ b/plugins/opt-multisig/hooks/useProposalApprovals.ts @@ -0,0 +1,39 @@ +import { useState, useEffect } from "react"; +import { Address, getAbiItem } from "viem"; +import { usePublicClient } from "wagmi"; +import { MultisigProposal, ApprovedEvent } from "../utils/types"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; +import { PUB_CHAIN } from "@/constants"; + +const event = getAbiItem({ + abi: OptimisticMultisigPluginAbi, + name: "Approved", +}); + +export function useProposalApprovals(pluginAddress: Address, proposalId: string, proposal: MultisigProposal | null) { + const publicClient = usePublicClient({ chainId: PUB_CHAIN.id }); + const [proposalLogs, setLogs] = useState([]); + + async function getLogs() { + if (!publicClient || !proposal?.parameters?.snapshotBlock) return; + + const logs = await publicClient.getLogs({ + address: pluginAddress, + event: event, + args: { + proposalId: BigInt(proposalId), + }, + fromBlock: proposal.parameters.snapshotBlock, + toBlock: "latest", // TODO: Make this variable between 'latest' and proposal last block + }); + + const newLogs = logs.flatMap((log) => log.args); + if (newLogs.length > proposalLogs.length) setLogs(newLogs); + } + + useEffect(() => { + getLogs(); + }, [!!publicClient, proposal?.parameters?.snapshotBlock]); + + return proposalLogs; +} diff --git a/plugins/opt-multisig/hooks/useProposalApprove.ts b/plugins/opt-multisig/hooks/useProposalApprove.ts new file mode 100644 index 0000000..d0b622e --- /dev/null +++ b/plugins/opt-multisig/hooks/useProposalApprove.ts @@ -0,0 +1,48 @@ +import { useProposal } from "./useProposal"; +import { useUserCanApprove } from "./useUserCanApprove"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; +import { PUB_OPT_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; +import { useProposalApprovals } from "./useProposalApprovals"; +import { useRouter } from "next/router"; +import { useTransactionManager } from "@/hooks/useTransactionManager"; + +export function useProposalApprove(proposalId: string) { + const { push } = useRouter(); + + const { proposal, status: proposalFetchStatus, refetch: refetchProposal } = useProposal(proposalId, true); + const { canApprove, refetch: refetchCanApprove } = useUserCanApprove(proposalId); + const approvals = useProposalApprovals(PUB_OPT_MULTISIG_PLUGIN_ADDRESS, proposalId, proposal); + + const { writeContract, status, isConfirming, isConfirmed } = useTransactionManager({ + onSuccessMessage: "Approval registered", + onSuccess() { + setTimeout(() => { + push("#/"); + window.scroll(0, 0); + }, 1000 * 2); + refetchCanApprove(); + refetchProposal(); + }, + onErrorMessage: "Could not approve the proposal", + onErrorDescription: "Check that you were part of the multisig when the proposal was created", + }); + + const approveProposal = () => { + writeContract({ + abi: OptimisticMultisigPluginAbi, + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + functionName: "approve", + args: [BigInt(proposalId), true], + }); + }; + + return { + proposal, + proposalFetchStatus, + approvals, + canApprove: !!canApprove, + isConfirming: status === "pending" || isConfirming, + isConfirmed, + approveProposal, + }; +} diff --git a/plugins/opt-multisig/hooks/useProposalExecute.ts b/plugins/opt-multisig/hooks/useProposalExecute.ts new file mode 100644 index 0000000..6019f33 --- /dev/null +++ b/plugins/opt-multisig/hooks/useProposalExecute.ts @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { useReadContract } from "wagmi"; +import { useRouter } from "next/router"; +import { PUB_CHAIN, PUB_OPT_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; +import { useTransactionManager } from "@/hooks/useTransactionManager"; + +export function useProposalExecute(proposalId: string) { + const { push } = useRouter(); + const [isExecuting, setIsExecuting] = useState(false); + + const { + data: canExecute, + isError: isCanVoteError, + isLoading: isCanVoteLoading, + } = useReadContract({ + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + abi: OptimisticMultisigPluginAbi, + chainId: PUB_CHAIN.id, + functionName: "canExecute", + args: [BigInt(proposalId)], + }); + + const { writeContract, isConfirming, isConfirmed } = useTransactionManager({ + onSuccessMessage: "Proposal executed", + onSuccess() { + setTimeout(() => { + push("#/"); + window.scroll(0, 0); + }, 1000 * 2); + }, + onErrorMessage: "Could not execute the proposal", + onErrorDescription: "The proposal may contain actions with invalid operations", + onError() { + setIsExecuting(false); + }, + }); + + const executeProposal = () => { + if (!canExecute) return; + + setIsExecuting(true); + + writeContract({ + chainId: PUB_CHAIN.id, + abi: OptimisticMultisigPluginAbi, + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + functionName: "execute", + args: [BigInt(proposalId)], + }); + }; + + return { + executeProposal, + canExecute: !isCanVoteError && !isCanVoteLoading && !isConfirmed && !!canExecute, + isConfirming: isExecuting || isConfirming, + isConfirmed, + }; +} diff --git a/plugins/opt-multisig/hooks/useProposalVariantStatus.ts b/plugins/opt-multisig/hooks/useProposalVariantStatus.ts new file mode 100644 index 0000000..6d6b22f --- /dev/null +++ b/plugins/opt-multisig/hooks/useProposalVariantStatus.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; +import { MultisigProposal } from "@/plugins/opt-multisig/utils/types"; +import { ProposalStatus } from "@aragon/ods"; + +export const useProposalVariantStatus = (proposal: MultisigProposal) => { + const [status, setStatus] = useState({ variant: "", label: "" }); + + useEffect(() => { + if (!proposal || !proposal?.parameters) return; + + if (proposal?.executed) { + setStatus({ variant: "primary", label: "Executed" }); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.expirationDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus({ variant: "critical", label: "Defeated" }); + } else { + setStatus({ variant: "critical", label: "Expired" }); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus({ variant: "success", label: "Executable" }); + } else { + setStatus({ variant: "info", label: "Active" }); + } + }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); + + return status; +}; + +export const useProposalStatus = (proposal: MultisigProposal) => { + const [status, setStatus] = useState(); + + useEffect(() => { + if (!proposal || !proposal?.parameters) return; + + if (proposal?.executed) { + setStatus(ProposalStatus.EXECUTED); + } else if (Math.floor(Date.now() / 1000) >= proposal.parameters.expirationDate) { + if (proposal.approvals < proposal.parameters.minApprovals) { + setStatus(ProposalStatus.REJECTED); + } else { + setStatus(ProposalStatus.EXPIRED); + } + } else if (proposal.approvals >= proposal.parameters.minApprovals) { + setStatus(ProposalStatus.EXECUTABLE); + } else { + setStatus(ProposalStatus.ACTIVE); + } + }, [proposal, proposal?.approvals, proposal?.executed, proposal?.parameters?.minApprovals]); + + return status; +}; diff --git a/plugins/opt-multisig/hooks/useUserCanApprove.ts b/plugins/opt-multisig/hooks/useUserCanApprove.ts new file mode 100644 index 0000000..6924702 --- /dev/null +++ b/plugins/opt-multisig/hooks/useUserCanApprove.ts @@ -0,0 +1,28 @@ +import { useAccount, useBlockNumber, useReadContract } from "wagmi"; +import { useEffect } from "react"; +import { PUB_CHAIN, PUB_OPT_MULTISIG_PLUGIN_ADDRESS } from "@/constants"; +import { OptimisticMultisigPluginAbi } from "../artifacts/OptimisticMultisigPlugin"; + +export function useUserCanApprove(proposalId: string | bigint | number) { + const { address } = useAccount(); + const { data: blockNumber } = useBlockNumber({ watch: true }); + + const { data: canApprove, refetch } = useReadContract({ + chainId: PUB_CHAIN.id, + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + abi: OptimisticMultisigPluginAbi, + functionName: "canApprove", + args: [BigInt(proposalId), address!], + query: { + enabled: !!address, + }, + }); + + useEffect(() => { + if (Number(blockNumber) % 2 === 0) { + refetch(); + } + }, [blockNumber]); + + return { canApprove, refetch }; +} diff --git a/plugins/opt-multisig/index.tsx b/plugins/opt-multisig/index.tsx new file mode 100644 index 0000000..8566156 --- /dev/null +++ b/plugins/opt-multisig/index.tsx @@ -0,0 +1,21 @@ +import { NotFound } from "@/components/not-found"; +import ProposalCreate from "./pages/new"; +import ProposalList from "./pages/proposal-list"; +import ProposalDetail from "./pages/proposal"; +import { useUrl } from "@/hooks/useUrl"; + +export default function PluginPage() { + // Select the inner pages to display depending on the URL hash + const { hash } = useUrl(); + + if (!hash || hash === "#/") return ; + else if (hash === "#/new") return ; + else if (hash.startsWith("#/proposals/")) { + const id = hash.replace("#/proposals/", ""); + + return ; + } + + // Default not found page + return ; +} diff --git a/plugins/opt-multisig/pages/new.tsx b/plugins/opt-multisig/pages/new.tsx new file mode 100644 index 0000000..d7b783f --- /dev/null +++ b/plugins/opt-multisig/pages/new.tsx @@ -0,0 +1,283 @@ +import { Button, IconType, InputText, Tag, TextAreaRichText } from "@aragon/ods"; +import React, { ReactNode, useState } from "react"; +import { RawAction } from "@/utils/types"; +import { Else, ElseIf, If, Then } from "@/components/if"; +import { MainSection } from "@/components/layout/main-section"; +import { useCreateProposal } from "../hooks/useCreateProposal"; +import { useAccount } from "wagmi"; +import { useCanCreateProposal } from "../hooks/useCanCreateProposal"; +import { MissingContentView } from "@/components/MissingContentView"; +import { useWeb3Modal } from "@web3modal/wagmi/react"; +import { Address } from "viem"; +import { NewActionDialog, NewActionType } from "@/components/dialogs/NewActionDialog"; +import { AddActionCard } from "@/components/cards/AddActionCard"; +import { ProposalActions } from "@/components/proposalActions/proposalActions"; +import { downloadAsFile } from "@/utils/download-as-file"; +import { encodeActionsAsJson } from "@/utils/json-actions"; + +export default function Create() { + const { address: selfAddress, isConnected } = useAccount(); + const { canCreate } = useCanCreateProposal(); + const [addActionType, setAddActionType] = useState(""); + const { + title, + summary, + description, + actions, + resources, + setTitle, + setSummary, + setDescription, + setActions, + setResources, + isCreating, + submitProposal, + } = useCreateProposal(); + + const handleTitleInput = (event: React.ChangeEvent) => { + setTitle(event?.target?.value); + }; + const handleSummaryInput = (event: React.ChangeEvent) => { + setSummary(event?.target?.value); + }; + const handleNewActionDialogClose = (newAction: RawAction[] | null) => { + if (!newAction) { + setAddActionType(""); + return; + } + + setActions(actions.concat(newAction)); + setAddActionType(""); + }; + const onRemoveAction = (idx: number) => { + actions.splice(idx, 1); + setActions([].concat(actions as any)); + }; + const removeResource = (idx: number) => { + resources.splice(idx, 1); + setResources([].concat(resources as any)); + }; + const onResourceNameChange = (event: React.ChangeEvent, idx: number) => { + resources[idx].name = event.target.value; + setResources([].concat(resources as any)); + }; + const onResourceUrlChange = (event: React.ChangeEvent, idx: number) => { + resources[idx].url = event.target.value; + setResources([].concat(resources as any)); + }; + + const exportAsJson = () => { + if (!actions.length) return; + + const strResult = encodeActionsAsJson(actions); + downloadAsFile("actions.json", strResult, "text/json"); + }; + + return ( + +
+

+ Create Proposal +

+ + +
+ +
+
+ +
+
+ +
+ +
+
+
+

Resources

+ +
+

+ Add links to external resources +

+
+
+ +

+ There are no resources yet. Click the button below to add the first one. +

+
+ {resources.map((resource, idx) => { + return ( +
+
+ onResourceNameChange(e, idx)} + placeholder="GitHub, Twitter, etc." + /> +
+ onResourceUrlChange(e, idx)} + placeholder="https://..." + readOnly={isCreating} + /> +
+ ); + })} +
+ + + +
+ + {/* Actions */} + + onRemoveAction(idx)} + /> + + + + + +
+ setAddActionType("withdrawal")} + /> + setAddActionType("select-abi-function")} + /> + setAddActionType("calldata")} + /> + setAddActionType("import-json")} + /> +
+ + {/* Dialog */} + + handleNewActionDialogClose(newActions)} + /> + + {/* Submit */} + +
+ +
+
+
+
+ ); +} + +const PlaceHolderOr = ({ + selfAddress, + isConnected, + canCreate, + children, +}: { + selfAddress: Address | undefined; + isConnected: boolean; + canCreate: boolean | undefined; + children: ReactNode; +}) => { + const { open } = useWeb3Modal(); + return ( + + + {/* Not connected */} + open()}> + Please connect your wallet to continue. + + + + {/* Not a member */} + + You cannot create proposals on the multisig because you are not currently defined as a member. + + + {children} + + ); +}; diff --git a/plugins/opt-multisig/pages/proposal-list.tsx b/plugins/opt-multisig/pages/proposal-list.tsx new file mode 100644 index 0000000..3d810d2 --- /dev/null +++ b/plugins/opt-multisig/pages/proposal-list.tsx @@ -0,0 +1,109 @@ +import { useAccount, useBlockNumber, useReadContract } from "wagmi"; +import { type ReactNode, useEffect } from "react"; +import ProposalCard from "@/plugins/opt-multisig/components/proposal"; +import { OptimisticMultisigPluginAbi } from "@/plugins/opt-multisig/artifacts/OptimisticMultisigPlugin"; +import { Button, DataList, IconType, ProposalDataListItemSkeleton, type DataListState } from "@aragon/ods"; +import { useCanCreateProposal } from "@/plugins/opt-multisig/hooks/useCanCreateProposal"; +import Link from "next/link"; +import { Else, ElseIf, If, Then } from "@/components/if"; +import { PUB_OPT_MULTISIG_PLUGIN_ADDRESS, PUB_CHAIN } from "@/constants"; +import { MainSection } from "@/components/layout/main-section"; +import { useWeb3Modal } from "@web3modal/wagmi/react"; +import { MissingContentView } from "@/components/MissingContentView"; + +const DEFAULT_PAGE_SIZE = 6; + +export default function Proposals() { + const { isConnected } = useAccount(); + const { canCreate } = useCanCreateProposal(); + const { open } = useWeb3Modal(); + + const { data: blockNumber } = useBlockNumber({ watch: true }); + + const { + data: proposalCountResponse, + error: isError, + isLoading, + isFetching: isFetchingNextPage, + refetch, + } = useReadContract({ + address: PUB_OPT_MULTISIG_PLUGIN_ADDRESS, + abi: OptimisticMultisigPluginAbi, + functionName: "proposalCount", + chainId: PUB_CHAIN.id, + }); + const proposalCount = Number(proposalCountResponse); + + useEffect(() => { + refetch(); + }, [blockNumber]); + + const entityLabel = proposalCount === 1 ? "Proposal" : "Proposals"; + + let dataListState: DataListState = "idle"; + if (isLoading && !proposalCount) { + dataListState = "initialLoading"; + } else if (isError) { + dataListState = "error"; + } else if (isFetchingNextPage) { + dataListState = "fetchingNextPage"; + } + + return ( + + +

+ Proposals +

+
+ + + + + +
+
+ + + + open()}> + Please connect your wallet to access the proposals section. + + + + + No proposals have been created yet.
+ Here you will see the proposals created by the Council before they can be submitted to the community veto + stage. Create your first proposal. +
+
+ + + + {proposalCount && + Array.from(Array(proposalCount || 0)?.keys()) + .reverse() + ?.map((proposalIndex) => ( + // TODO: update with router agnostic ODS DataListItem + + ))} + + + + +
+
+ ); +} + +function SectionView({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/plugins/opt-multisig/pages/proposal.tsx b/plugins/opt-multisig/pages/proposal.tsx new file mode 100644 index 0000000..6d8ad31 --- /dev/null +++ b/plugins/opt-multisig/pages/proposal.tsx @@ -0,0 +1,111 @@ +import { type useProposal } from "@/plugins/opt-multisig/hooks/useProposal"; +import ProposalHeader from "@/plugins/opt-multisig/components/proposal/header"; +import { PleaseWaitSpinner } from "@/components/please-wait"; +import { useProposalApprove } from "@/plugins/opt-multisig/hooks/useProposalApprove"; +import { useProposalExecute } from "@/plugins/opt-multisig/hooks/useProposalExecute"; +import { BodySection } from "@/components/proposal/proposalBodySection"; +import { ProposalVoting } from "@/components/proposalVoting"; +import { type ITransformedStage, type IVote, ProposalStages } from "@/utils/types"; +import { useProposalStatus } from "../hooks/useProposalVariantStatus"; +import dayjs from "dayjs"; +import { ProposalActions } from "@/components/proposalActions/proposalActions"; +import { CardResources } from "@/components/proposal/cardResources"; + +export default function ProposalDetail({ id: proposalId }: { id: string }) { + const { + proposal, + proposalFetchStatus, + canApprove, + approvals, + isConfirming: isConfirmingApproval, + approveProposal, + } = useProposalApprove(proposalId); + + const { executeProposal, canExecute, isConfirming: isConfirmingExecution } = useProposalExecute(proposalId); + + const showProposalLoading = getShowProposalLoading(proposal, proposalFetchStatus); + const proposalStatus = useProposalStatus(proposal!); + + const proposalStage: ITransformedStage[] = [ + { + id: "1", + type: ProposalStages.MULTISIG_APPROVAL, + variant: "approvalThreshold", + title: "Onchain multisig", + status: proposalStatus!, + disabled: false, + proposalId: proposalId, + providerId: "1", + result: { + cta: proposal?.executed + ? { + disabled: true, + label: "Executed", + } + : canExecute + ? { + isLoading: isConfirmingExecution, + label: "Execute", + onClick: executeProposal, + } + : { + disabled: !canApprove, + isLoading: isConfirmingApproval, + label: "Approve", + onClick: approveProposal, + }, + approvalAmount: proposal?.approvals || 0, + approvalThreshold: proposal?.parameters.minApprovals || 0, + }, + details: { + censusBlock: Number(proposal?.parameters.snapshotBlock), + startDate: "", + endDate: dayjs(Number(proposal?.parameters.expirationDate) * 1000).toString(), + strategy: "Approval threshold", + options: "Approve", + }, + votes: approvals.map(({ approver }) => ({ address: approver, variant: "approve" }) as IVote), + }, + ]; + + if (!proposal || showProposalLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+
+ + + +
+
+ +
+
+
+
+ ); +} + +function getShowProposalLoading( + proposal: ReturnType["proposal"], + status: ReturnType["status"] +) { + if (!proposal && status.proposalLoading) return true; + else if (status.metadataLoading && !status.metadataError) return true; + else if (!proposal?.title && !status.metadataError) return true; + + return false; +} diff --git a/plugins/opt-multisig/utils/types.ts b/plugins/opt-multisig/utils/types.ts new file mode 100644 index 0000000..0ecb313 --- /dev/null +++ b/plugins/opt-multisig/utils/types.ts @@ -0,0 +1,39 @@ +import { Address } from "viem"; +import { IProposalResource, RawAction } from "@/utils/types"; + +export type ProposalInputs = { + proposalId: bigint; +}; + +export type MultisigProposalResultType = readonly [ + boolean, + number, + MultisigProposalParameters, + string, + readonly RawAction[], + Address, +]; + +export type MultisigProposalParameters = { + expirationDate: bigint; + snapshotBlock: bigint; + minApprovals: number; +}; + +export type MultisigProposal = { + executed: boolean; + parameters: MultisigProposalParameters; + approvals: number; + actions: RawAction[]; + allowFailureMap: bigint; + creator: string; + title: string; + summary: string; + description: string; + resources: IProposalResource[]; +}; + +export type ApprovedEvent = { + proposalId?: bigint; + approver?: Address; +}; diff --git a/plugins/optimistic-proposals/components/vote/vetoes-section.tsx b/plugins/optimistic-proposals/components/vote/vetoes-section.tsx index 91fbc2f..c31adf5 100644 --- a/plugins/optimistic-proposals/components/vote/vetoes-section.tsx +++ b/plugins/optimistic-proposals/components/vote/vetoes-section.tsx @@ -28,10 +28,12 @@ const VetoCard = function ({ veto }: { veto: VetoCastEvent }) {
- +
{veto?.voter} -

{compactNumber(formatUnits(veto.votingPower, 18))} votes

+

+ {compactNumber(formatUnits(veto.votingPower || BigInt(0), 18))} votes +

diff --git a/plugins/optimistic-proposals/hooks/useProposalVariantStatus.ts b/plugins/optimistic-proposals/hooks/useProposalVariantStatus.ts index 44ded3c..ae9eae3 100644 --- a/plugins/optimistic-proposals/hooks/useProposalVariantStatus.ts +++ b/plugins/optimistic-proposals/hooks/useProposalVariantStatus.ts @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { OptimisticProposal } from "@/plugins/optimistic-proposals/utils/types"; +import { OptimisticProposal } from "../utils/types"; import { ProposalStatus } from "@aragon/ods"; import { useToken } from "./useToken"; import { PUB_BRIDGE_ADDRESS } from "@/constants"; @@ -19,15 +19,15 @@ export const useProposalVariantStatus = (proposal: OptimisticProposal) => { const effectiveSupply = proposal.parameters.skipL2 ? totalSupply - bridgedBalance : totalSupply; const minVetoVotingPower = (effectiveSupply * BigInt(proposal.parameters.minVetoRatio)) / BigInt(1_000_000); - setStatus( - proposal?.vetoTally >= minVetoVotingPower - ? { variant: "critical", label: "Defeated" } - : proposal?.active - ? { variant: "info", label: "Active" } - : proposal?.executed - ? { variant: "primary", label: "Executed" } - : { variant: "success", label: "Executable" } - ); + if (proposal?.active) { + setStatus({ variant: "info", label: "Active" }); + } else if (proposal?.executed) { + setStatus({ variant: "primary", label: "Executed" }); + } else if (proposal?.vetoTally >= minVetoVotingPower) { + setStatus({ variant: "critical", label: "Defeated" }); + } else { + setStatus({ variant: "success", label: "Executable" }); + } }, [ proposal?.vetoTally, proposal?.active, @@ -54,15 +54,15 @@ export const useProposalStatus = (proposal: OptimisticProposal) => { const effectiveSupply = proposal.parameters.skipL2 ? totalSupply - bridgedBalance : totalSupply; const minVetoVotingPower = (effectiveSupply * BigInt(proposal.parameters.minVetoRatio)) / BigInt(1_000_000); - setStatus( - proposal?.vetoTally >= minVetoVotingPower - ? ProposalStatus.VETOED - : proposal?.active - ? ProposalStatus.ACTIVE - : proposal?.executed - ? ProposalStatus.EXECUTED - : ProposalStatus.ACCEPTED - ); + if (proposal?.active) { + setStatus(ProposalStatus.ACTIVE); + } else if (proposal?.executed) { + setStatus(ProposalStatus.EXECUTED); + } else if (proposal?.vetoTally >= minVetoVotingPower) { + setStatus(ProposalStatus.VETOED); + } else { + setStatus(ProposalStatus.ACCEPTED); + } }, [ proposal?.vetoTally, proposal?.active, diff --git a/plugins/tokenVoting/components/vote/votes-section.tsx b/plugins/tokenVoting/components/vote/votes-section.tsx index a0aa2e3..d56bf19 100644 --- a/plugins/tokenVoting/components/vote/votes-section.tsx +++ b/plugins/tokenVoting/components/vote/votes-section.tsx @@ -28,10 +28,12 @@ const VetoCard = function ({ veto }: { veto: VoteCastEvent }) {
- +
{veto?.voter} -

{compactNumber(formatUnits(veto.votingPower, 18))} votes

+

+ {compactNumber(formatUnits(veto.votingPower || BigInt(0), 18))} votes +

diff --git a/plugins/tokenVoting/hooks/useProposalVoteList.ts b/plugins/tokenVoting/hooks/useProposalVoteList.ts index 94a8b40..da88e1c 100644 --- a/plugins/tokenVoting/hooks/useProposalVoteList.ts +++ b/plugins/tokenVoting/hooks/useProposalVoteList.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { getAbiItem } from "viem"; import { TokenVotingAbi } from "../artifacts/TokenVoting.sol"; -import { Proposal, VoteCastEvent, VoteCastResponse } from "../utils/types"; +import { Proposal, VoteCastEvent } from "../utils/types"; import { usePublicClient } from "wagmi"; import { PUB_TOKEN_VOTING_PLUGIN_ADDRESS } from "@/constants"; @@ -15,7 +15,7 @@ export function useProposalVoteList(proposalId: number, proposal: Proposal | nul if (!proposal?.parameters?.snapshotBlock) return; else if (!publicClient) return; - const logs: VoteCastResponse[] = (await publicClient.getLogs({ + const logs = await publicClient.getLogs({ address: PUB_TOKEN_VOTING_PLUGIN_ADDRESS, event: event, args: { @@ -23,7 +23,7 @@ export function useProposalVoteList(proposalId: number, proposal: Proposal | nul }, fromBlock: proposal.parameters.snapshotBlock, toBlock: "latest", // TODO: Make this variable between 'latest' and proposal last block - })) as any; + }); const newLogs = logs.flatMap((log) => log.args); if (newLogs.length > proposalLogs.length) setLogs(newLogs); diff --git a/plugins/tokenVoting/utils/types.tsx b/plugins/tokenVoting/utils/types.tsx index b69f077..9ff0434 100644 --- a/plugins/tokenVoting/utils/types.tsx +++ b/plugins/tokenVoting/utils/types.tsx @@ -40,13 +40,9 @@ export type Proposal = { resources: IProposalResource[]; }; -export type VoteCastResponse = { - args: VoteCastEvent[]; -}; - export type VoteCastEvent = { - voter: Address; - proposalId: bigint; - voteOption: number; - votingPower: bigint; + voter?: Address; + proposalId?: bigint; + voteOption?: number; + votingPower?: bigint; };