diff --git a/src/modules/etherlink/components/EvmDaoProposalList.tsx b/src/modules/etherlink/components/EvmDaoProposalList.tsx index 981ae24c..50176d8e 100644 --- a/src/modules/etherlink/components/EvmDaoProposalList.tsx +++ b/src/modules/etherlink/components/EvmDaoProposalList.tsx @@ -45,6 +45,11 @@ export const EvmDaoProposalList: React.FC = ({ proposals, showFullList = const [isLoading, setIsLoading] = useState(false) console.log("EvmDaoProposalList", proposals) + console.log( + "EvmDaoProposalListX", + proposals?.filter((p: any) => p.proposalData?.length > 0).map((p: any) => p.type) + ) + const pageCount = Math.ceil(proposals ? proposals.length / offsetLimit : 0) // Invoke when user click to request another page. diff --git a/src/modules/etherlink/components/EvmDaoSettingsModal.tsx b/src/modules/etherlink/components/EvmDaoSettingsModal.tsx index 0f9e6d70..cb4016e6 100644 --- a/src/modules/etherlink/components/EvmDaoSettingsModal.tsx +++ b/src/modules/etherlink/components/EvmDaoSettingsModal.tsx @@ -90,7 +90,7 @@ export const EvmDaoSettingModal: React.FC<{ value: daoSelected?.votingDelay }, { - key: "Execution Delay (minutes)", + key: "Execution Delay (seconds)", value: daoSelected?.executionDelay } ] diff --git a/src/modules/etherlink/components/EvmProposalCountdown.tsx b/src/modules/etherlink/components/EvmProposalCountdown.tsx index 8cfb7e07..1a92bbee 100644 --- a/src/modules/etherlink/components/EvmProposalCountdown.tsx +++ b/src/modules/etherlink/components/EvmProposalCountdown.tsx @@ -6,6 +6,7 @@ import dayjs from "dayjs" import React, { useContext, useEffect, useMemo, useState } from "react" import { EtherlinkContext } from "services/wagmi/context" import { GridContainer } from "modules/common/GridContainer" +import { ProposalStatus } from "services/services/dao/mappers/proposal/types" interface TimeLeft { days: number @@ -30,8 +31,12 @@ export const EvmProposalCountdown = () => { return "Time left to vote" } + if (daoProposalSelected?.status === ProposalStatus.PASSED) { + return "Execution available in" + } + return "Voting concluded" - }, [votingStartTimestamp, votingExpiresAt]) + }, [votingStartTimestamp, votingExpiresAt, daoProposalSelected?.status]) useEffect(() => { const calculateTimeLeft = () => { @@ -88,8 +93,8 @@ export const EvmProposalCountdown = () => { ) return ( - - + + {timerLabel} diff --git a/src/modules/etherlink/components/EvmProposalDetail.tsx b/src/modules/etherlink/components/EvmProposalDetail.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/src/modules/etherlink/components/EvmProposalVoteDetail.tsx b/src/modules/etherlink/components/EvmProposalVoteDetail.tsx index 5030389c..e77c7d68 100644 --- a/src/modules/etherlink/components/EvmProposalVoteDetail.tsx +++ b/src/modules/etherlink/components/EvmProposalVoteDetail.tsx @@ -3,7 +3,6 @@ import React, { useContext, useEffect, useMemo, useState } from "react" import { Button, Grid, styled, Theme, Typography, useMediaQuery, useTheme } from "@material-ui/core" import { GridContainer } from "modules/common/GridContainer" -import { VotesDialog } from "modules/lite/explorer/components/VotesDialog" import { Poll } from "models/Polls" import { Choice } from "models/Choice" import ProgressBar from "react-customizable-progressbar" @@ -11,11 +10,10 @@ import ProgressBar from "react-customizable-progressbar" import { useTezos } from "services/beacon/hooks/useTezos" import { getTurnoutValue } from "services/utils/utils" import { useTokenDelegationSupported } from "services/contracts/token/hooks/useTokenDelegationSupported" -import { DownloadCsvFile } from "modules/lite/explorer/components/DownloadCsvFile" import { EtherlinkContext } from "services/wagmi/context" import { LinearProgress } from "components/ui/LinearProgress" -import { formatNumber } from "modules/explorer/utils/FormatNumber" import { useEvmDaoOps } from "services/contracts/etherlinkDAO/hooks/useEvmDaoOps" +import { EVM_PROPOSAL_CHOICES } from "../config" const Container = styled(Grid)(({ theme }) => ({ background: theme.palette.primary.main, @@ -93,12 +91,11 @@ const HistoryValue = styled(Typography)({ export const EvmProposalVoteDetail: React.FC<{ poll: Poll | undefined - choices: Choice[] token: any -}> = ({ poll, choices, token }) => { +}> = ({ poll, token }) => { const theme = useTheme() const isMobileSmall = useMediaQuery(theme.breakpoints.down("xs")) - + const choices = EVM_PROPOSAL_CHOICES const { network } = useTezos() const [turnout, setTurnout] = useState() const [votes, setVotes] = useState([]) @@ -122,8 +119,6 @@ export const EvmProposalVoteDetail: React.FC<{ // setVotes(choices.filter(elem => elem.walletAddresses.length > 0)) } - console.log({ showProposalVoterList }) - useMemo(async () => { if (token && tokenData) { const value = await getTurnoutValue( @@ -139,7 +134,7 @@ export const EvmProposalVoteDetail: React.FC<{ } }, [poll, network, token, tokenData, totalVoteCount]) - const votesQuorumPercentage = 50 + const votesQuorumPercentage = daoProposalSelected?.votesWeightPercentage console.log({ daoProposalSelected }) @@ -278,7 +273,7 @@ export const EvmProposalVoteDetail: React.FC<{ /> */} - + {/* Quorum */} diff --git a/src/modules/etherlink/config.ts b/src/modules/etherlink/config.ts index d84e21ab..9d10dbb7 100644 --- a/src/modules/etherlink/config.ts +++ b/src/modules/etherlink/config.ts @@ -100,44 +100,63 @@ export const EvmProposalOptions = [ } ] +// TODO: Ensure tags are unique export const proposalInterfaces = [ { + tags: ["registry"], interface: ["function editRegistry(string key, string Value)"], name: "editRegistry" }, { + tags: ["token"], interface: ["function mint(address to, uint256 amount)"], name: "mint" }, { + tags: ["token"], interface: ["function burn(address from, uint256 amount)"], name: "burn" }, { + tags: ["transfer"], interface: ["function transferETH(address to, uint256 amount)"], name: "transferETH" }, { + tags: ["token", "mint", "burn"], interface: ["function transferERC20(address token, address to, uint256 amount)"], name: "transferERC20" }, { + tags: ["token"], interface: ["function transferERC721(address token, address to, uint256 tokenId)"], name: "transferERC721" }, { + tags: ["quorum"], + label: "Update Quorum", + unit: "%", interface: ["function updateQuorumNumerator(uint256 newQuorumNumerator)"], name: "updateQuorumNumerator" }, { + tags: ["voting delay"], + label: "Update Voting Delay", + unit: "seconds", interface: ["function setVotingDelay(uint256 newVotingDelay)"], name: "setVotingDelay" }, { + tags: ["voting period"], + label: "Update Voting Period", + unit: "seconds", interface: ["function setVotingPeriod(uint256 newVotingPeriod)"], name: "setVotingPeriod" }, { + tags: ["proposal threshold"], + label: "Update Proposal Threshold", + unit: "tokens", interface: ["function setProposalThreshold(uint256 newProposalThreshold)"], name: "setProposalThreshold" } diff --git a/src/modules/etherlink/explorer/EtherlinkDAO/EvmProposalDetailsPage.tsx b/src/modules/etherlink/explorer/EtherlinkDAO/EvmProposalDetailsPage.tsx index 8a536eab..5a8c8a3c 100644 --- a/src/modules/etherlink/explorer/EtherlinkDAO/EvmProposalDetailsPage.tsx +++ b/src/modules/etherlink/explorer/EtherlinkDAO/EvmProposalDetailsPage.tsx @@ -1,5 +1,5 @@ import { GridContainer } from "modules/common/GridContainer" -import { Button, Grid, Typography, useMediaQuery, useTheme } from "@mui/material" +import { Button, Grid, TableRow, TableBody, Table, Typography, useMediaQuery, useTheme, TableCell } from "@mui/material" import { PageContainer } from "components/ui/DaoCreator" import { useContext, useEffect, useState } from "react" import { useParams } from "react-router-dom" @@ -7,21 +7,148 @@ import { EtherlinkContext } from "services/wagmi/context" import { EvmProposalDetailCard } from "modules/etherlink/components/EvmProposalDetailCard" import { EvmProposalVoteDetail } from "modules/etherlink/components/EvmProposalVoteDetail" import { EvmProposalCountdown } from "modules/etherlink/components/EvmProposalCountdown" -import { EVM_PROPOSAL_CHOICES } from "modules/etherlink/config" import { EvmProposalVoterList } from "modules/etherlink/components/EvmProposalVoterList" import { ThumbDownAlt } from "@mui/icons-material" import { ThumbUpAlt } from "@mui/icons-material" import { useNotification } from "modules/common/hooks/useNotification" import { useEvmProposalOps } from "services/contracts/etherlinkDAO/hooks/useEvmProposalOps" +import { ProposalStatus } from "services/services/dao/mappers/proposal/types" -export const EvmProposalDetailsPage = () => { +const RenderProposalAction = () => { + const { daoProposalSelected } = useContext(EtherlinkContext) const [isCastingVote, setIsCastingVote] = useState(false) const openNotification = useNotification() + const { castVote, queueForExecution, executeProposal } = useEvmProposalOps() + + if (daoProposalSelected?.status === ProposalStatus.PASSED) { + return ( + + + + ) + } + + if (daoProposalSelected?.status === ProposalStatus.EXECUTABLE) { + return ( + + + + ) + } + + if (daoProposalSelected?.status === ProposalStatus.EXECUTED) { + return ( + + + + ) + } + + if (daoProposalSelected?.status === ProposalStatus.ACTIVE) { + return ( + <> + + + + + + + + + ) + } + + return ( + + + Proposal is not active +
+ (No Quorum) +
+
+ ) +} + +export const EvmProposalDetailsPage = () => { const params = useParams() as { proposalId: string } const proposalId = params?.proposalId const { daoSelected, daoProposalSelected, selectDaoProposal } = useContext(EtherlinkContext) - const { castVote } = useEvmProposalOps() const theme = useTheme() const isMobileSmall = useMediaQuery(theme.breakpoints.down("sm")) @@ -30,8 +157,6 @@ export const EvmProposalDetailsPage = () => { selectDaoProposal(proposalId) }, [proposalId, selectDaoProposal]) - const choices = EVM_PROPOSAL_CHOICES - return (
@@ -39,111 +164,97 @@ export const EvmProposalDetailsPage = () => { - - - - - - - - - - - {choices && choices.length > 0 ? ( - - - {/* {[choices].map((choice, index) => { - return ( - {}} - /> - ) - })} */} - - {/* {poll?.isActive === ProposalStatus.ACTIVE ? ( - - ) : null} */} - - ) : null} - + + - - {/* {poll && poll !== undefined ? ( - - ) : null} */} - + + + - + + + + + + Proposal Data + + + {daoProposalSelected?.proposalData?.map( + ({ parameter, value }: { parameter: string; value: string }, idx: number) => { + return ( + + + + + + Parameter + + + {parameter} + + + + + Value + + + {value} + + + +
+
+ ) + } + )} +
+
+
) } + +const UnusedBlock = ({ choices, isMobileSmall }: { choices: any; isMobileSmall: boolean }) => ( + + {choices && choices.length > 0 ? ( + + + {/* {[choices].map((choice, index) => { + return ( + {}} + /> + ) + })} */} + + {/* {poll?.isActive === ProposalStatus.ACTIVE ? ( + + ) : null} */} + + ) : null} + +) diff --git a/src/modules/etherlink/utils.ts b/src/modules/etherlink/utils.ts index 11c96800..d6d1f5a9 100644 --- a/src/modules/etherlink/utils.ts +++ b/src/modules/etherlink/utils.ts @@ -1,7 +1,324 @@ +import { ethers, FunctionFragment } from "ethers" +import { proposalInterfaces } from "./config" + export const isInvalidEvmAddress = (address: string) => { - return true + return !ethers.isAddress(address) } export const validateEvmTokenAddress = (address: string) => { - return true + return ethers.isAddress(address) +} + +export const getCallDataFromBytes = (bytes: any) => { + const cdBytes = bytes.toUint8Array() + const hexString = Array.from(cdBytes) + .map((byte: unknown) => (byte as number).toString(16).padStart(2, "0")) + .join("") + return `0x${hexString}` +} + +export function parseTransactionHash(input: string): string { + // Equivalent to input.substring(2, input.length - 1) + input = input.substring(2, input.length - 1) + + const byteValues: number[] = [] + + for (let i = 0; i < input.length; i++) { + if (input[i] === "\\" && input[i + 1] === "x") { + // Extract the two hex characters following '\x' + const hexValue = input.substring(i + 2, i + 4) + byteValues.push(parseInt(hexValue, 16)) + i += 3 // Skip past the processed '\xNN' sequence + } else { + // Add ASCII code for literal characters + byteValues.push(input.charCodeAt(i)) + } + } + + // Convert bytes to hex string with zero-padding + const hexString = byteValues.map(byte => byte.toString(16).padStart(2, "0")).join("") + + return hexString +} + +export const getCallDataForProposal = ( + proposalType: + | "quorum" + | "voting delay" + | "voting period" + | "proposal threshold" + | "quorumNumerator" + | "votingDelay" + | "votingPeriod" + | "proposalThreshold", + { + title, + description, + externalResource + }: { + title: string + description: string + externalResource: string + }, + value: any +) => { + let ifaceDef, iface: any, encodedData: any + + if (["quorum", "quorumNumerator"].includes(proposalType)) { + ifaceDef = proposalInterfaces.find(p => p.name === "updateQuorumNumerator") + if (!ifaceDef) return + iface = new ethers.Interface(ifaceDef.interface) + encodedData = iface.encodeFunctionData(ifaceDef.name, [value]) + } + if (["voting delay", "votingDelay"].includes(proposalType)) { + ifaceDef = proposalInterfaces.find(p => p.name === "setVotingDelay") + if (!ifaceDef) return + iface = new ethers.Interface(ifaceDef.interface) + encodedData = iface.encodeFunctionData(ifaceDef.name, [value]) + } + if (["voting period", "votingPeriod"].includes(proposalType)) { + ifaceDef = proposalInterfaces.find(p => p.name === "setVotingPeriod") + if (!ifaceDef) return + iface = new ethers.Interface(ifaceDef.interface) + encodedData = iface.encodeFunctionData(ifaceDef.name, [value]) + } + if (["proposal threshold", "proposalThreshold"].includes(proposalType)) { + ifaceDef = proposalInterfaces.find(p => p.name === "setProposalThreshold") + if (!ifaceDef) return + iface = new ethers.Interface(ifaceDef.interface) + encodedData = iface.encodeFunctionData(ifaceDef.name, [value]) + } + return encodedData +} + +export function decodeCalldataWithEthers(functionAbi: string, callDataHex: string, isRetry = false): any { + // try { + // const callDataHexValue = !callDataHex?.startsWith("0x") ? `0x${callDataHex}` : callDataHex + // const selector = callDataHexValue.slice(0, 10) // "0xa9059cbb" + // console.log("decodeCalldataWithEthers:Function Selector:", selector) + + // const paramTypes = ["address", "uint256", "uint8"] + // const argsData = "0x" + callDataHexValue.slice(10) + // console.log("decodeCalldataWithEthers:Args Data:", argsData) + + // const decodedArgs = ethers.AbiCoder.defaultAbiCoder().decode(paramTypes, argsData) + // console.log("decodeCalldataWithEthers:Decoded Args:", decodedArgs) + + // return { decoded: [], functionName: "", decodedData: [] } + // } catch (error) { + // console.log("error:decodeCalldataWithEthers", functionAbi, callDataHex, error) + // return { decoded: [], functionName: "", decodedData: [] } + // } + + const callDataHexValue = callDataHex.startsWith("0x") ? callDataHex : `0x${callDataHex}` + + try { + const iface = new ethers.Interface([functionAbi]) + const decoded = iface.decodeFunctionData(functionAbi, callDataHexValue) + const functionName = iface.getFunction(functionAbi)?.name + return { decoded, functionName, decodedData: convertBigIntToString(decoded) } + } catch (error) { + if (isRetry) { + console.log("error:decodeCalldataWithEthers", { + functionAbi, + callDataHexValue, + error + }) + return { decoded: [], functionName: "", decodedData: [] } + } + + return decodeCalldataWithEthers(functionAbi, callDataHex, true) + } +} + +// TODO: Remove this function later +export function decodeFunctionParameters(functionAbi: FunctionFragment | string, hexString: string): any[] { + if (typeof functionAbi === "string") { + functionAbi = FunctionFragment.from(functionAbi) + } + + // Utility function to convert hex string to Uint8Array + const hexToBytes = (hex: string): Uint8Array => { + return ethers.getBytes(hex) + } + + // Utility function to convert a subset of bytes to BigInt + const bytesToBigInt = (bytes: Uint8Array): bigint => { + return BigInt("0x" + Buffer.from(bytes).toString("hex")) + } + + // Convert the hex string to bytes + const dataBytes: Uint8Array = hexToBytes(hexString) + + // Remove the first 4 bytes (function selector) + const dataWithoutSelector: Uint8Array = dataBytes.slice(4) + + // Initialize decoding variables + let offset = 0 + const decodedParams: any[] = [] + + for (const param of functionAbi.inputs) { + const paramType = param.type + + if (paramType === "string") { + // String type decoding (dynamic) + + // Read the 32-byte offset + const paramOffsetBytes = dataWithoutSelector.slice(offset, offset + 32) + const paramOffset: bigint = bytesToBigInt(paramOffsetBytes) + const paramOffsetInt = Number(paramOffset) + + // Decode length of the string + const lengthBytes = dataWithoutSelector.slice(paramOffsetInt, paramOffsetInt + 32) + const length: bigint = bytesToBigInt(lengthBytes) + const lengthInt = Number(length) + + // Extract the actual string data + const stringBytes = dataWithoutSelector.slice(paramOffsetInt + 32, paramOffsetInt + 32 + lengthInt) + const decodedString = ethers.toUtf8String(stringBytes) + + decodedParams.push(decodedString) + } else if (paramType === "address") { + // Address type decoding (last 20 bytes of the 32-byte slot) + const addressBytes = dataWithoutSelector.slice(offset + 12, offset + 32) + const addressHex = ethers.hexlify(addressBytes) + const checksumAddress = ethers.getAddress(addressHex) + + decodedParams.push(checksumAddress) + } else if (paramType.startsWith("uint") || paramType.startsWith("int")) { + // Uint or Int type decoding (entire 32 bytes) + const uintBytes = dataWithoutSelector.slice(offset, offset + 32) + const uintValue = bytesToBigInt(uintBytes).toString() + + decodedParams.push(uintValue) + } else { + throw new Error(`Unsupported parameter type: ${paramType}`) + } + + // Move to the next 32-byte slot + offset += 32 + } + + return decodedParams +} + +export function convertBigIntToString(obj: unknown): unknown { + if (typeof obj === "bigint") { + return obj.toString() + } else if (Array.isArray(obj)) { + return obj.map(convertBigIntToString) + } else if (obj && typeof obj === "object") { + const newObj: Record = {} + for (const [key, value] of Object.entries(obj)) { + newObj[key] = convertBigIntToString(value) + } + return newObj + } + return obj +} + +export function decodeFunctionParametersLegacy(functionAbiString: string, hexString: string): any[] { + const functionAbi = FunctionFragment.from(functionAbiString) + console.log("functionAbi", functionAbi) + // Helper function to convert a hex string to a Uint8Array + function hexToBytes(hex: string): Uint8Array { + if (hex.startsWith("0x")) { + hex = hex.slice(2) + } + if (hex.length % 2 !== 0) { + hex = "0" + hex + } + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.substr(i * 2, 2), 16) + } + return bytes + } + + // Helper function to convert a Uint8Array to a BigInt + function bytesToBigInt(bytes: Uint8Array): bigint { + let value = BigInt(0) + for (let i = 0; i < bytes.length; i++) { + value = (value << BigInt(8)) + BigInt(bytes[i]) + } + return value + } + + // Helper function to convert a Uint8Array to a hex string + function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, "0")) + .join("") + } + + try { + // Convert the hex string to bytes + const dataBytes = hexToBytes(hexString) + + // Ensure the data is at least 4 bytes for the function selector + if (dataBytes.length < 4) { + throw new Error("Hex string is too short to contain a function selector.") + } + + // Remove the first 4 bytes (function selector) + const dataWithoutSelector = dataBytes.slice(4) + + // Initialize decoding variables + let offset = 0 + const decodedParams: any[] = [] + + for (const param of functionAbi.inputs) { + switch (param.type) { + case "string": { + // Decode the offset to the string data + const paramOffsetBytes = dataWithoutSelector.slice(offset, offset + 32) + const paramOffset = Number(bytesToBigInt(paramOffsetBytes)) + + // Decode the length of the string + const lengthBytes = dataWithoutSelector.slice(paramOffset, paramOffset + 32) + const length = Number(bytesToBigInt(lengthBytes)) + + // Extract the actual string data + const stringBytes = dataWithoutSelector.slice(paramOffset + 32, paramOffset + 32 + length) + const decoder = new TextDecoder() + const decodedString = decoder.decode(stringBytes) + + decodedParams.push(decodedString) + break + } + + case "address": { + // Decode the address (last 20 bytes of the 32-byte slot) + const addressBytes = dataWithoutSelector.slice(offset + 12, offset + 32) + const addressHex = "0x" + bytesToHex(addressBytes) + decodedParams.push(addressHex) + break + } + + default: + if (param.type.startsWith("uint")) { + // Decode unsigned integers (e.g., uint256) + const uintBytes = dataWithoutSelector.slice(offset, offset + 32) + const uintValue = bytesToHex(uintBytes) + // const uintValueHex = ethers.hexlify(uintBytes) + // const uintValueBigNumber = ethers.toNumber(uintValueHex) + // const uintValueBigNumberX = ethers.toNumber(uintBytes) + decodedParams.push({ + value: uintValue, + bytes: uintBytes + }) + } else { + throw new Error(`Unsupported parameter type: ${param.type}`) + } + } + + // Move to the next 32-byte slot + offset += 32 + } + + return decodedParams + } catch (error) { + console.log("error:decodeFunctionParametersLegacy", { functionAbiString, hexString, error }) + return [] + } } diff --git a/src/services/contracts/etherlinkDAO/hooks/useEvmProposalOps.tsx b/src/services/contracts/etherlinkDAO/hooks/useEvmProposalOps.tsx index 3b739e68..169953e5 100644 --- a/src/services/contracts/etherlinkDAO/hooks/useEvmProposalOps.tsx +++ b/src/services/contracts/etherlinkDAO/hooks/useEvmProposalOps.tsx @@ -218,6 +218,7 @@ const useEvmProposalCreateZustantStore = create()( const payload = { daoConfig: { ...get().daoConfig, [type]: value } } as any // TODO: handle this within next handler console.log("setting dao config", type, value) + // TODO: Replace this with getCallDataForProposal let ifaceDef, iface: any, encodedData: any if (type === "quorumNumerator") { ifaceDef = proposalInterfaces.find(p => p.name === "updateQuorumNumerator") @@ -340,7 +341,7 @@ const useEvmProposalCreateZustantStore = create()( export const useEvmProposalOps = () => { const [isLoading, setIsLoading] = useState(false) const { etherlink } = useTezos() - const { daoSelected } = useContext(EtherlinkContext) + const { daoSelected, daoProposalSelected } = useContext(EtherlinkContext) const router = useHistory() const zustantStore = useEvmProposalCreateZustantStore() @@ -354,7 +355,38 @@ export const useEvmProposalOps = () => { return new ethers.Contract(daoSelected?.address, HbDaoAbi.abi, etherlink.signer) }, [daoSelected?.address, etherlink.signer]) - console.log("currentStep", currentStep, proposalType) + const getProposalExecutionMetadata = useCallback(() => { + if (!daoContract || !daoProposalSelected?.id || !daoSelected?.address) + return alert("No dao contract or proposal id") + + const concatenatedDescription = [ + daoProposalSelected.title, + daoProposalSelected.type, + daoProposalSelected.description, + daoProposalSelected.externalResource + ].join("0|||0") + + const encodedInput = ethers.toUtf8Bytes(concatenatedDescription) + const keccakHash = ethers.keccak256(encodedInput) + const hashHex = keccakHash + console.log(`queueForExecution: Keccak-256 hash: ${hashHex}`) + const callData = daoProposalSelected?.callDataPlain?.[0] + const expectedCallData = "0x06f3f9e6000000000000000000000000000000000000000000000000000000000000000f" + console.log(`queueForExecution: callData: ${callData}`, expectedCallData) + return { + calldata: [callData], + hashHex: hashHex + } + }, [ + daoContract, + daoProposalSelected?.callDataPlain, + daoProposalSelected.description, + daoProposalSelected.externalResource, + daoProposalSelected?.id, + daoProposalSelected.title, + daoProposalSelected.type, + daoSelected?.address + ]) const createProposal = useCallback( async (payload: Record) => { @@ -382,18 +414,35 @@ export const useEvmProposalOps = () => { [daoContract] ) - const executeProposal = useCallback( - async (proposalId: number) => { - if (!daoContract) return + const queueForExecution = useCallback(async () => { + if (!daoContract || !daoProposalSelected?.id || !daoSelected?.address) + return alert("No dao contract or proposal id") - const tx = await daoContract.execute(proposalId) - console.log("Execute transaction sent:", tx.hash) - const receipt = await tx.wait() - console.log("Execute transaction confirmed:", receipt) - return receipt - }, - [daoContract] - ) + const metadata = getProposalExecutionMetadata() + if (!metadata) return alert("Could not get proposal metadata") + console.log("proposalAction metadata", daoSelected?.address, metadata) + const tx = await daoContract.queue([daoSelected?.address], [0], metadata.calldata, metadata.hashHex) + console.log("Queue transaction sent:", tx.hash) + + const receipt = await tx.wait() + console.log("Queue transaction confirmed:", receipt) + + return receipt + }, [daoContract, daoProposalSelected?.id, daoSelected?.address, getProposalExecutionMetadata]) + + const executeProposal = useCallback(async () => { + if (!daoContract) return + + const metadata = getProposalExecutionMetadata() + if (!metadata) return alert("Could not get proposal metadata") + + const tx = await daoContract.execute([daoSelected?.address], [0], metadata.calldata, metadata.hashHex) + console.log("Execute transaction sent:", tx.hash) + + const receipt = await tx.wait() + console.log("Execute transaction confirmed:", receipt) + return receipt + }, [daoContract, daoSelected?.address, getProposalExecutionMetadata]) const nextStep = { text: isLoading ? "Please wait..." : "Next", @@ -494,6 +543,7 @@ export const useEvmProposalOps = () => { setIsLoading, createProposal, castVote, + queueForExecution, executeProposal, signer: etherlink?.signer, nextStep, diff --git a/src/services/wagmi/context.tsx b/src/services/wagmi/context.tsx index 9a10f9b2..534ccd12 100644 --- a/src/services/wagmi/context.tsx +++ b/src/services/wagmi/context.tsx @@ -12,6 +12,13 @@ import dayjs from "dayjs" import { ethers } from "ethers" import BigNumber from "bignumber.js" import { Timestamp } from "firebase/firestore" +import { + decodeCalldataWithEthers, + decodeFunctionParametersLegacy, + getCallDataFromBytes, + parseTransactionHash +} from "modules/etherlink/utils" +import { proposalInterfaces } from "modules/etherlink/config" interface EtherlinkType { isConnected: boolean @@ -67,9 +74,10 @@ const useEtherlinkDao = ({ network }: { network: string }) => { proposalThreshold: string totalSupply: string registry: Record - votingDuration: number - votingDelay: number + votingDuration: number // in minutes + votingDelay: number // in minutes quorum: number + executionDelay: number // in seconds } | null>(null) const [daoRegistryDetails, setDaoRegistryDetails] = useState<{ balance: string @@ -87,6 +95,11 @@ const useEtherlinkDao = ({ network }: { network: string }) => { }[] >([]) + console.log( + "AllCallData", + daoProposals?.map((x: any) => x.callDataPlain?.[0]) + ) + const [daoMembers, setDaoMembers] = useState([]) const { data: firestoreData, loading, fetchCollection } = useFirestoreStore() @@ -133,11 +146,14 @@ const useEtherlinkDao = ({ network }: { network: string }) => { .map(firebaseProposal => { const votesInFavor = new BigNumber(firebaseProposal?.inFavor) const votesAgainst = new BigNumber(firebaseProposal?.against) + const votesInFavorWeight = new BigNumber(firebaseProposal?.inFavor) const totalVotes = votesInFavor.plus(votesAgainst) const totalVoteCount = parseInt(firebaseProposal?.votesFor) + parseInt(firebaseProposal?.votesAgainst) const totalSupply = new BigNumber(daoSelected?.totalSupply ?? "1") const votesPercentage = totalVotes.div(totalSupply).times(100) + const daoMinimumQuorum = new BigNumber(daoSelected?.quorum ?? "0") + const daoTotalVotingWeight = new BigNumber(daoSelected?.totalSupply ?? "0") console.log("votesPercentage", firebaseProposal?.title, votesPercentage.toString()) const proposalCreatedAt = dayjs.unix(firebaseProposal.createdAt?.seconds as unknown as number) @@ -161,42 +177,70 @@ const useEtherlinkDao = ({ network }: { network: string }) => { .map(([status, timestamp]: [string, any]) => ({ status, timestamp: timestamp?.seconds as unknown as number, - timestamp_human: dayjs.unix(timestamp?.seconds as unknown as number).format("MMM DD, YYYY HH:mm:ss") + timestamp_human: dayjs.unix(timestamp?.seconds as unknown as number).format("MMM DD, YYYY hh:mm A") })) .sort((a, b) => b.timestamp - a.timestamp) statusHistoryMap.push({ status: "active", timestamp: activeStartTimestamp.unix(), - timestamp_human: activeStartTimestamp.format("MMM DD, YYYY HH:mm:ss") + timestamp_human: activeStartTimestamp.format("MMM DD, YYYY hh:mm A") }) - console.log({ - votesPercentage: votesPercentage.toFormat(2), - quorum: daoSelected?.quorum, - votingEndTimestamp, - timeNow - }) + if (votesInFavorWeight.div(daoTotalVotingWeight).times(100).gt(daoMinimumQuorum)) { + statusHistoryMap.push({ + status: "passed", + timestamp: votingEndTimestamp.unix(), + timestamp_human: votingEndTimestamp.format("MMM DD, YYYY hh:mm A") + }) + } + + const statusQueued = statusHistoryMap.find(x => x.status === "queued") + if (statusQueued) { + const executionDelayInSeconds = daoSelected?.executionDelay || 0 + const proposalExecutableAt = statusQueued.timestamp + executionDelayInSeconds + console.log("proposalExecutableAt", proposalExecutableAt, timeNow.unix()) + if (proposalExecutableAt < timeNow.unix()) { + statusHistoryMap.push({ + status: "executable", + timestamp: proposalExecutableAt, + timestamp_human: dayjs.unix(proposalExecutableAt).format("MMM DD, YYYY hh:mm A") + }) + } + } + if (votesPercentage.lt(daoSelected?.quorum) && votingEndTimestamp.isBefore(timeNow)) { statusHistoryMap.push({ status: "no quorum", timestamp: votingEndTimestamp.unix(), - timestamp_human: votingEndTimestamp.format("MMM DD, YYYY HH:mm") + timestamp_human: votingEndTimestamp.format("MMM DD, YYYY hh:mm A") }) } + console.log({ statusHistoryMap }) + + const callDatas = firebaseProposal?.callDatas + const callDataPlain = callDatas?.map((x: any) => getCallDataFromBytes(x)) + return { ...firebaseProposal, + callDataPlain, status: getStatusByHistory(statusHistoryMap, { votesPercentage, votingExpiresAt, activeStartTimestamp, - daoQuorum: daoSelected?.quorum + daoQuorum: daoSelected?.quorum, + executionDelayInSeconds: daoSelected?.executionDelay }), + proposalData: [], statusHistoryMap: statusHistoryMap.sort((a, b) => b.timestamp - a.timestamp), votingStartTimestamp: activeStartTimestamp, votingExpiresAt: votingExpiresAt, totalVotes: totalVotes, - totalVoteCount + totalVoteCount, + votesWeightPercentage: Number(votesPercentage.toFixed(2)), + txHash: firebaseProposal?.executionHash + ? `https://testnet.explorer.etherlink.com/tx/0x${parseTransactionHash(firebaseProposal?.executionHash)}` + : "" } }) ) @@ -252,6 +296,31 @@ const useEtherlinkDao = ({ network }: { network: string }) => { selectDaoProposal: (proposalId: string) => { const proposal = daoProposals.find((proposal: any) => proposal.id === proposalId) if (proposal) { + const proposalInterface = proposalInterfaces.find((x: any) => { + let fbType = proposal?.type?.toLowerCase() + if (fbType?.startsWith("mint")) fbType = "mint" + if (fbType?.startsWith("burn")) fbType = "burn" + return x.tags?.includes(fbType) + }) + const functionAbi = proposalInterface?.interface?.[0] as string + if (!functionAbi) return [] + + const proposalData = proposalInterface + ? proposal?.callDataPlain?.map((callData: any) => { + const formattedCallData = callData.startsWith("0x") ? callData : `0x${callData}` + const decodedDataPair = decodeCalldataWithEthers(functionAbi, formattedCallData) + const decodedDataPairLegacy = decodeFunctionParametersLegacy(functionAbi, formattedCallData) + const functionName = decodedDataPair?.functionName + const functionParams = decodedDataPair?.decodedData + const proposalInterface = proposalInterfaces.find((x: any) => x.name === functionName) + + const label = proposalInterface?.label + console.log("decodedDataPair", decodedDataPair, functionName, functionParams) + console.log("decodedDataPairLegacy", decodedDataPairLegacy) + return { parameter: label || functionName, value: functionParams.join(", ") } + }) + : [] + proposal.proposalData = proposalData setDaoProposalSelected(proposal) } }, @@ -274,12 +343,14 @@ const getStatusByHistory = ( votesPercentage, votingExpiresAt, activeStartTimestamp, - daoQuorum + daoQuorum, + executionDelayInSeconds }: { votesPercentage: BigNumber votingExpiresAt: dayjs.Dayjs activeStartTimestamp: dayjs.Dayjs daoQuorum: number + executionDelayInSeconds: number } ) => { const timeNow = dayjs() @@ -295,11 +366,16 @@ const getStatusByHistory = ( return maxStatus }) + console.log("getStatusByHistory", { status, history }) if (activeStartTimestamp.isAfter(timeNow) && votingExpiresAt.isBefore(timeNow)) { return ProposalStatus.ACTIVE } // TODO: @ashutoshpw, handle more statuses switch (status.status) { + case "queued": + return ProposalStatus.PASSED + case "passed": + return ProposalStatus.PASSED case "active": return ProposalStatus.ACTIVE case "pending": @@ -311,6 +387,8 @@ const getStatusByHistory = ( return ProposalStatus.REJECTED case "accepted": return ProposalStatus.ACTIVE + case "executable": + return ProposalStatus.EXECUTABLE case "executed": return ProposalStatus.EXECUTED default: