diff --git a/dapp/src/components/page/proposal/ExecuteProposalModal.tsx b/dapp/src/components/page/proposal/ExecuteProposalModal.tsx index 9264633..7c1eca2 100644 --- a/dapp/src/components/page/proposal/ExecuteProposalModal.tsx +++ b/dapp/src/components/page/proposal/ExecuteProposalModal.tsx @@ -1,22 +1,56 @@ import React from "react"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import JsonView from "react18-json-view"; import { modifySlashInXdr } from "utils/utils"; import { stellarLabViewXdrLink } from "constants/serviceLinks"; import * as StellarXdr from "utils/stellarXdr"; import { parseToLosslessJson } from "utils/passToLosslessJson"; +import type { ProposalOutcome, VoteStatus, VoteType } from "types/proposal"; interface VotersModalProps { - xdr: string; + projectName: string; + proposalId: number | undefined; + outcome: ProposalOutcome | null; + voteStatus: VoteStatus | null; onClose: () => void; } -const VotersModal: React.FC = ({ xdr, onClose }) => { +const VotersModal: React.FC = ({ + projectName, + proposalId, + outcome, + voteStatus, + onClose, +}) => { const [content, setContent] = useState(null); - const signAndExecute = () => {}; + const voteResultAndXdr: { voteResult: VoteType | null; xdr: string | null } = + useMemo(() => { + if (voteStatus && outcome) { + const { approve, abstain } = voteStatus; + let voteResult: VoteType | null = null; + let xdr: string | null = null; + if (approve.score > abstain.score) { + voteResult = "approve"; + xdr = outcome?.approved?.xdr || null; + } else if (approve.score < abstain.score) { + voteResult = "reject"; + xdr = outcome?.rejected?.xdr || null; + } else { + voteResult = "abstain"; + xdr = outcome?.cancelled?.xdr || null; + } + return { voteResult, xdr }; + } else { + return { voteResult: null, xdr: null }; + } + }, [voteStatus, outcome]); + + useEffect(() => { + getContentFromXdr(voteResultAndXdr.xdr); + }, [voteResultAndXdr.xdr]); - const getContentFromXdr = async (_xdr: string) => { + const getContentFromXdr = async (_xdr: string | null) => { try { if (_xdr) { const decoded = StellarXdr.decode("TransactionEnvelope", _xdr); @@ -27,9 +61,42 @@ const VotersModal: React.FC = ({ xdr, onClose }) => { } }; - useEffect(() => { - getContentFromXdr(xdr); - }, [xdr]); + const signAndExecute = async () => { + if (!projectName) { + alert("Project name is required"); + return; + } + + if (proposalId === undefined) { + alert("Proposal ID is required"); + return; + } + + if (!voteResultAndXdr.voteResult) { + alert("Vote result is required"); + return; + } + + if (!voteResultAndXdr.xdr) { + alert("XDR is required"); + return; + } + + const { executeProposal } = await import("@service/WriteContractService"); + const res = await executeProposal( + projectName, + proposalId, + voteResultAndXdr.xdr, + ); + if (res.error) { + alert(res.errorMessage); + onClose(); + } else { + console.log("execute result:", res.data); + alert("Proposal executed successfully"); + onClose(); + } + }; return (
= ({ xdr, onClose }) => {
Outcome
= ({ xdr, onClose }) => { > diff --git a/dapp/src/components/page/proposal/ProposalPage.tsx b/dapp/src/components/page/proposal/ProposalPage.tsx index a822489..14e1d0a 100644 --- a/dapp/src/components/page/proposal/ProposalPage.tsx +++ b/dapp/src/components/page/proposal/ProposalPage.tsx @@ -58,12 +58,13 @@ const ProposalPage: React.FC = () => { }; const openExecuteProposalModal = () => { - if (proposal?.status === "active") { + if (proposal?.status === "voted") { setIsExecuteProposalModalOpen(true); } else { alert("Cannot execute proposal."); } }; + const getProposalDetails = async () => { if (id !== undefined && projectName) { setIsLoading(true); @@ -149,7 +150,10 @@ const ProposalPage: React.FC = () => { )} {isExecuteProposalModalOpen && ( setIsExecuteProposalModalOpen(false)} /> )} diff --git a/dapp/src/components/page/proposal/VotingModal.tsx b/dapp/src/components/page/proposal/VotingModal.tsx index 588e57d..c1d966c 100644 --- a/dapp/src/components/page/proposal/VotingModal.tsx +++ b/dapp/src/components/page/proposal/VotingModal.tsx @@ -4,7 +4,7 @@ import type { VoteType } from "types/proposal"; interface VotersModalProps { projectName: string; - proposalId: number; + proposalId: number | undefined; isVoted: boolean; setIsVoted: React.Dispatch>; onClose: () => void; @@ -34,6 +34,10 @@ const VotingModal: React.FC = ({ setIsLoading(true); const { voteToProposal } = await import("@service/WriteContractService"); + if (proposalId === undefined) { + alert("Proposal ID is required"); + return; + } const res = await voteToProposal(projectName, proposalId, selectedOption); if (res.data) { diff --git a/dapp/src/constants/contractErrorMessages.ts b/dapp/src/constants/contractErrorMessages.ts index 0885f84..975e7cc 100644 --- a/dapp/src/constants/contractErrorMessages.ts +++ b/dapp/src/constants/contractErrorMessages.ts @@ -10,6 +10,7 @@ export const contractErrorMessages = { 8: "Proposal or page could not be found.", 9: "You have already voted.", 10: "The proposal voting time has expired.", + 11: "The proposal has already been executed.", }; export type ContractErrorMessageKey = keyof typeof contractErrorMessages; diff --git a/dapp/src/service/WriteContractService.ts b/dapp/src/service/WriteContractService.ts index 166f1aa..3a3dedf 100644 --- a/dapp/src/service/WriteContractService.ts +++ b/dapp/src/service/WriteContractService.ts @@ -3,7 +3,14 @@ import { loadedPublicKey } from "./walletService"; import Versioning from "../contracts/soroban_versioning"; import { loadedProjectId } from "./StateService"; -import { keccak256 } from "js-sha3"; +import * as pkg from "js-sha3"; +const { keccak256 } = pkg; +import { + TransactionBuilder, + Transaction, + xdr, + rpc, +} from "@stellar/stellar-sdk"; import type { Vote } from "soroban_versioning"; import type { VoteType } from "types/proposal"; import type { Response } from "types/response"; @@ -12,6 +19,8 @@ import { type ContractErrorMessageKey, } from "constants/contractErrorMessages"; +const server = new rpc.Server(import.meta.env.PUBLIC_SOROBAN_RPC_URL); + // Function to map VoteType to Vote function mapVoteTypeToVote(voteType: VoteType): Vote { switch (voteType) { @@ -276,10 +285,131 @@ async function voteToProposal( } } +async function execute( + project_name: string, + proposal_id: number, +): Promise> { + const publicKey = loadedPublicKey(); + + if (!publicKey) { + return { + data: false, + error: true, + errorCode: -1, + errorMessage: "Please connect your wallet first", + }; + } else { + Versioning.options.publicKey = publicKey; + } + + const project_key = Buffer.from( + keccak256.create().update(project_name).digest(), + ); + + const tx = await Versioning.execute({ + maintainer: publicKey, + project_key: project_key, + proposal_id: Number(proposal_id), + }); + + try { + const result = await tx.signAndSend({ + signTransaction: async (xdr) => { + return await kit.signTransaction(xdr); + }, + }); + return { + data: result.result, + error: false, + errorCode: -1, + errorMessage: "", + }; + } catch (e) { + console.error(e); + const { errorCode, errorMessage } = fetchErrorCode(e); + return { data: null, error: true, errorCode, errorMessage }; + } +} + +async function executeProposal( + project_name: string, + proposal_id: number, + executeXdr: string, +): Promise> { + const publicKey = loadedPublicKey(); + + if (!publicKey) { + return { + data: false, + error: true, + errorCode: -1, + errorMessage: "Please connect your wallet first", + }; + } + + const res = await execute(project_name, proposal_id); + if (res.error) { + return res; + } + + const executorAccount = await server.getAccount(publicKey); + try { + const outcomeTransactionEnvelope = xdr.TransactionEnvelope.fromXDR( + executeXdr, + "base64", + ); + + const outcomeTransaction = outcomeTransactionEnvelope.v1().tx(); + + const transactionBuilder = new TransactionBuilder(executorAccount, { + fee: import.meta.env.PUBLIC_DEFAULT_FEE, + networkPassphrase: import.meta.env.PUBLIC_SOROBAN_NETWORK_PASSPHRASE, + }); + + outcomeTransaction.operations().forEach((operation) => { + transactionBuilder.addOperation(operation); + }); + + const compositeTransaction = transactionBuilder.setTimeout(180).build(); + + const { signedTxXdr } = await kit.signTransaction( + compositeTransaction.toXDR(), + ); + + const signedTransaction = new Transaction( + signedTxXdr, + import.meta.env.PUBLIC_SOROBAN_NETWORK_PASSPHRASE, + ); + + const result = await server.sendTransaction(signedTransaction); + + if (result.status === "ERROR") { + return { + data: null, + error: true, + errorCode: -1, + errorMessage: "Transaction failed", + }; + } else { + return { + data: result.hash, + error: false, + errorCode: -1, + errorMessage: "", + }; + } + } catch (e) { + console.error(e); + const { errorCode, errorMessage } = fetchErrorCode(e); + return { data: null, error: true, errorCode, errorMessage }; + } +} + export { commitHash, registerProject, updateConfig, createProposal, voteToProposal, + executeProposal, }; diff --git a/dapp/src/utils/store.js b/dapp/src/utils/store.js index 691b26a..0942b97 100644 --- a/dapp/src/utils/store.js +++ b/dapp/src/utils/store.js @@ -4,5 +4,5 @@ export const projectInfoLoaded = atom(false); export const latestCommit = atom(""); export const projectCardModalOpen = atom(false); export const projectNameForGovernance = atom(""); -export const proposalId = atom(0); +export const proposalId = atom(undefined); export const connectedPublicKey = atom("");