diff --git a/artifacts/ERC20Permit.sol.ts b/artifacts/ERC20Permit.sol.ts new file mode 100644 index 00000000..c6b8ddca --- /dev/null +++ b/artifacts/ERC20Permit.sol.ts @@ -0,0 +1,526 @@ +import { Abi } from "viem"; +export const ERC20PermitAbi: Abi = [ + { + "inputs": [], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [], + "name": "ECDSAInvalidSignature", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "length", + "type": "uint256" + } + ], + "name": "ECDSAInvalidSignatureLength", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "ECDSAInvalidSignatureS", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "allowance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientAllowance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "balance", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "needed", + "type": "uint256" + } + ], + "name": "ERC20InsufficientBalance", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC20InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC20InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC20InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "ERC20InvalidSpender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + } + ], + "name": "ERC2612ExpiredSignature", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "signer", + "type": "address" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ERC2612InvalidSigner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + }, + { + "internalType": "uint256", + "name": "currentNonce", + "type": "uint256" + } + ], + "name": "InvalidAccountNonce", + "type": "error" + }, + { + "inputs": [], + "name": "InvalidShortString", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "str", + "type": "string" + } + ], + "name": "StringTooLong", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [], + "name": "EIP712DomainChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [], + "name": "DOMAIN_SEPARATOR", + "outputs": [ + { + "internalType": "bytes32", + "name": "", + "type": "bytes32" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "eip712Domain", + "outputs": [ + { + "internalType": "bytes1", + "name": "fields", + "type": "bytes1" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "version", + "type": "string" + }, + { + "internalType": "uint256", + "name": "chainId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "verifyingContract", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "uint256[]", + "name": "extensions", + "type": "uint256[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "nonces", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "deadline", + "type": "uint256" + }, + { + "internalType": "uint8", + "name": "v", + "type": "uint8" + }, + { + "internalType": "bytes32", + "name": "r", + "type": "bytes32" + }, + { + "internalType": "bytes32", + "name": "s", + "type": "bytes32" + } + ], + "name": "permit", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transfer", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "value", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] \ No newline at end of file diff --git a/hooks/usePermit.ts b/hooks/usePermit.ts new file mode 100644 index 00000000..e33aa02e --- /dev/null +++ b/hooks/usePermit.ts @@ -0,0 +1,109 @@ +import { useEffect } from "react"; +import { + useReadContracts, + useSignTypedData, + useAccount, +} from "wagmi"; +import { hexToSignature, Address } from "viem"; +import { ERC20PermitAbi } from "@/artifacts/ERC20Permit.sol"; +import { useAlertContext, AlertContextProps } from "@/context/AlertContext"; +import { PUB_CHAIN, PUB_TOKEN_ADDRESS } from "@/constants"; + +export function usePermit() { + const { addAlert } = useAlertContext() as AlertContextProps; + + const account_address = useAccount().address!; + const erc20Contract = { + address: PUB_TOKEN_ADDRESS, + abi: ERC20PermitAbi, + }; + const { data: erc20data, refetch: erc20refetch } = useReadContracts({ + contracts: [{ + ...erc20Contract, + functionName: "nonces", + args: [account_address], + },{ + ...erc20Contract, + functionName: "name", + },{ + ...erc20Contract, + functionName: "version", + }] + }); + const [nonceResult, nameResult, versionResult] = erc20data || []; + + const { signTypedDataAsync: permitSign, status: permitSignStatus, error: permitSignError } = useSignTypedData(); + + useEffect(() => { + switch (permitSignStatus) { + case "idle": + case "pending": + return; + case "error": + if (permitSignError?.message?.startsWith("User rejected the request")) { + addAlert("Transaction rejected by the user", { + timeout: 4 * 1000, + }); + } else { + addAlert("Could not sign the permit", { type: "error", timeout: 1500 }); + } + return; + case "success": + addAlert("Permit signed", { type: "success", timeout: 1500 }); + return; + } + }, [permitSignStatus]); + + const signPermit = async (dest: Address, value: BigInt, deadline: BigInt = BigInt(Math.floor(Date.now() / 1000) + 60 * 60)) => { + if (!nonceResult || !nameResult || !versionResult) return; + + const nonce = BigInt(Number(nonceResult?.result)); + const erc20_name = String(nameResult?.result); + /* We assume 1 if permit version is not specified */ + const versionFromContract = String(versionResult?.result ?? '1'); + + const domain = { + chainId: PUB_CHAIN.id, + name: erc20_name, + version: versionFromContract, + verifyingContract: PUB_TOKEN_ADDRESS, + }; + + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const message = { + owner: account_address, + spender: dest, + value, + nonce, + deadline, + }; + + try { + let sig = await permitSign({ + account: account_address, + types, + domain, + primaryType: 'Permit', + message, + }); + + return hexToSignature(sig); + } catch (e) { + return; + } + }; + + return { + refetchPermitData: erc20refetch, + signPermit, + }; +} diff --git a/plugins/lockToVote/components/proposal/details.tsx b/plugins/lockToVote/components/proposal/details.tsx index 7bbdd917..61f20a0a 100644 --- a/plugins/lockToVote/components/proposal/details.tsx +++ b/plugins/lockToVote/components/proposal/details.tsx @@ -1,19 +1,31 @@ import dayjs from "dayjs"; +import { compactNumber } from "@/utils/numbers"; import { ReactNode } from "react"; +import { formatUnits } from "viem"; interface ProposalDetailsProps { minVetoVotingPower?: bigint; endDate?: bigint; - snapshotBlock?: bigint; } const ProposalDetails: React.FC = ({ /** Timestamp */ endDate, - snapshotBlock, + minVetoVotingPower, }) => { return ( <> + +

+ Threshold +

+
+

Min. Quorum

+ + {compactNumber(formatUnits(minVetoVotingPower || BigInt(0), 18))} + +
+

Ending @@ -27,23 +39,13 @@ const ProposalDetails: React.FC = ({

- -

- Snapshot -

-
-

Taken at block

- - {snapshotBlock?.toLocaleString()} - -
-
+ ); }; // This should be encapsulated as soon as ODS exports this widget -const Card = function ({ children }: { children: ReactNode }) { +const Card = function({ children }: { children: ReactNode }) { return (

- Vetoed + For

{compactNumber(formatUnits(voteCount || BigInt(0), 18))} @@ -27,7 +27,7 @@ const VetoTally: FC = ({ voteCount, votePercentage }) => ( ); // This should be encapsulated as soon as ODS exports this widget -const Card = function ({ children }: { children: ReactNode }) { +const Card = function({ children }: { children: ReactNode }) { return (

{ + if (!canExecute) return; + + executeWrite({ + chainId: PUB_CHAIN.id, + abi: OptimisticTokenVotingPluginAbi, + address: PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS, + functionName: "execute", + args: [proposalId], + }); + }; + + useEffect(() => { + if (executingStatus === "idle" || executingStatus === "pending") return; + else if (executingStatus === "error") { + if (executingError?.message?.startsWith("User rejected the request")) { + addAlert("Transaction rejected by the user", { + timeout: 4 * 1000, + }); + } else { + console.error(executingError); + addAlert("Could not execute the proposal", { + type: "error", + description: + "The proposal may contain actions with invalid operations", + }); + } + return; + } + + // success + if (!executeTxHash) return; + else if (isConfirming) { + addAlert("Proposal submitted", { + description: "Waiting for the transaction to be validated", + type: "info", + txHash: executeTxHash, + }); + return; + } else if (!isConfirmed) return; + + addAlert("Proposal executed", { + description: "The transaction has been validated", + type: "success", + txHash: executeTxHash, + }); + + setTimeout(() => reload(), 1000 * 2); + }, [executingStatus, executeTxHash, isConfirming, isConfirmed]); + + return { + executeProposal, + canExecute: + !isCanVoteError && !isCanVoteLoading && !isConfirmed && !!canExecute, + isConfirming, + isConfirmed, + }; +} diff --git a/plugins/lockToVote/hooks/useProposalVariantStatus.tsx b/plugins/lockToVote/hooks/useProposalVariantStatus.tsx index 174d1259..d7008907 100644 --- a/plugins/lockToVote/hooks/useProposalVariantStatus.tsx +++ b/plugins/lockToVote/hooks/useProposalVariantStatus.tsx @@ -7,13 +7,13 @@ export const useProposalVariantStatus = (proposal: Proposal) => { useEffect(() => { if (!proposal || !proposal?.parameters) return; setStatus( - proposal?.vetoTally >= proposal?.parameters?.minVetoVotingPower - ? { variant: 'critical', label: 'Defeated' } + proposal?.vetoTally >= proposal?.parameters?.minVetoVotingPower + ? { variant: 'success', label: 'Executable' } : proposal?.active ? { variant: 'primary', label: 'Active' } - : proposal?.executed + : proposal?.executed ? { variant: 'success', label: 'Executed' } - : { variant: 'success', label: 'Executable' } + : { variant: 'critical', label: 'Defeated' } ); }, [proposal?.vetoTally, proposal?.active, proposal?.executed, proposal?.parameters?.minVetoVotingPower]); diff --git a/plugins/lockToVote/hooks/useProposalVeto.tsx b/plugins/lockToVote/hooks/useProposalVeto.tsx index 27df6d4e..1968d9ec 100644 --- a/plugins/lockToVote/hooks/useProposalVeto.tsx +++ b/plugins/lockToVote/hooks/useProposalVeto.tsx @@ -3,14 +3,18 @@ import { usePublicClient, useWaitForTransactionReceipt, useWriteContract, + useReadContract, + useAccount, } from "wagmi"; +import { Address } from "viem"; +import { ERC20PermitAbi } from "@/artifacts/ERC20Permit.sol"; import { useProposal } from "./useProposal"; import { useProposalVetoes } from "@/plugins/lockToVote/hooks/useProposalVetoes"; import { useUserCanVeto } from "@/plugins/lockToVote/hooks/useUserCanVeto"; -import { OptimisticTokenVotingPluginAbi } from "@/plugins/lockToVote/artifacts/OptimisticTokenVotingPlugin.sol"; +import { LockToVetoPluginAbi } from "@/plugins/lockToVote/artifacts/LockToVetoPlugin.sol"; +import { usePermit } from "@/hooks/usePermit"; import { useAlertContext, AlertContextProps } from "@/context/AlertContext"; -import { PUB_CHAIN, PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS } from "@/constants"; -import { LockToVetoPluginAbi } from "../artifacts/LockToVetoPlugin.sol"; +import { PUB_CHAIN, PUB_TOKEN_ADDRESS, PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS } from "@/constants"; export function useProposalVeto(proposalId: string) { const publicClient = usePublicClient({ chainId: PUB_CHAIN.id }); @@ -26,8 +30,18 @@ export function useProposalVeto(proposalId: string) { proposalId, proposal ); + const { signPermit, refetchPermitData } = usePermit(); const { addAlert } = useAlertContext() as AlertContextProps; + const account_address = useAccount().address!; + + const { data: balanceData } = useReadContract({ + address: PUB_TOKEN_ADDRESS, + abi: ERC20PermitAbi, + functionName: "balanceOf", + args: [account_address], + }); + const { writeContract: vetoWrite, data: vetoTxHash, @@ -70,14 +84,23 @@ export function useProposalVeto(proposalId: string) { }); refetchCanVeto(); refetchProposal(); + refetchPermitData(); }, [vetoingStatus, vetoTxHash, isConfirming, isConfirmed]); const vetoProposal = () => { - vetoWrite({ - abi: LockToVetoPluginAbi, - address: PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS, - functionName: "veto", - args: [proposalId, 50000000000000000000], + let dest: Address = PUB_LOCK_TO_VOTE_PLUGIN_ADDRESS; + let value = BigInt(Number(balanceData)); + let deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 60); // 1 hour from now + + signPermit(dest, value, deadline).then((sig) => { + if (!sig) return; + + vetoWrite({ + abi: LockToVetoPluginAbi, + address: dest, + functionName: "vetoPermit", + args: [proposalId, value, deadline, sig.v, sig.r, sig.s], + }); }); }; diff --git a/plugins/lockToVote/pages/proposal.tsx b/plugins/lockToVote/pages/proposal.tsx index 9c429782..5c1bfb8d 100644 --- a/plugins/lockToVote/pages/proposal.tsx +++ b/plugins/lockToVote/pages/proposal.tsx @@ -10,7 +10,7 @@ import { PleaseWaitSpinner } from "@/components/please-wait"; import { useSkipFirstRender } from "@/hooks/useSkipFirstRender"; import { useState } from "react"; import { useProposalVeto } from "@/plugins/lockToVote/hooks/useProposalVeto"; -import { useProposalExecute } from "@/plugins/dualGovernance/hooks/useProposalExecute"; +import { useProposalExecute } from "@/plugins/lockToVote/hooks/useProposalExecute"; import { useProposalClaimLock } from "@/plugins/lockToVote/hooks/useProposalClaimLock"; import { useAccount } from "wagmi"; @@ -86,7 +86,7 @@ export default function ProposalDetail({ id: proposalId }: { id: string }) { />
diff --git a/utils/ipfs.ts b/utils/ipfs.ts index 17b4611c..0c3d65a1 100644 --- a/utils/ipfs.ts +++ b/utils/ipfs.ts @@ -1,6 +1,6 @@ import { PUB_IPFS_ENDPOINT, PUB_IPFS_API_KEY } from "@/constants"; import { CID, IPFSHTTPClient } from "ipfs-http-client"; -import { fromHex } from "viem"; +import { fromHex, Address } from "viem"; export function fetchJsonFromIpfs(hexIpfsUri: string) { return fetchFromIPFS(hexIpfsUri).then((res) => res.json()); @@ -34,7 +34,7 @@ async function fetchFromIPFS(hexIpfsUri: string): Promise { } function getPath(hexIpfsUri: string) { - const decodedUri = fromHex(hexIpfsUri as `0x${string}`, "string"); + const decodedUri = fromHex(hexIpfsUri as Address, "string"); const path = decodedUri.includes("ipfs://") ? decodedUri.substring(7) : decodedUri;