From 72526320d08f2d40fa893c2cde1e604e9ce49411 Mon Sep 17 00:00:00 2001 From: DEVEUNICE Date: Sat, 27 Jun 2026 00:33:23 +0000 Subject: [PATCH] feat(frontend): add transaction simulation and preview (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SimulationError class to api.ts — distinct from real tx failures - Add simulateWriteCall() helper: builds and simulates a state-changing transaction against the RPC node, returns SimulationPreview with estimated fee in stroops; throws SimulationError on contract rejection vs plain Error on network/RPC failure - Add simulateCastVote() and simulateCreateProposal() convenience wrappers - Add VoteSimulationPreview component: radio group for Yes/No/Abstain, Simulate button, success panel (estimated fee), contract-rejection panel (yellow, distinct styling), network-error panel (red) - Integrate VoteSimulationPreview into ProposalDetail — shown when wallet is connected, proposal is Active, and user has not yet voted Closes #288 --- frontend/src/api.ts | 120 +++++++++++++-- frontend/src/components/ProposalDetail.tsx | 9 ++ .../src/components/VoteSimulationPreview.tsx | 145 ++++++++++++++++++ 3 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/VoteSimulationPreview.tsx diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 42adda0..8af3d0b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,19 +12,32 @@ import type { Proposal, VoteRecord } from './types'; const server = new SorobanRpc.Server(config.rpcUrl); -// Simulate a read-only contract call without a real account -async function simulateCall( +// --------------------------------------------------------------------------- +// Simulation error — distinct from real on-chain failures +// --------------------------------------------------------------------------- + +export class SimulationError extends Error { + constructor( + message: string, + public readonly raw: SorobanRpc.Api.SimulateTransactionResponse, + ) { + super(message); + this.name = 'SimulationError'; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function buildTx( + senderOrDummy: string, contractId: string, method: string, - ...args: xdr.ScVal[] -): Promise { - // Use a zero-sequence dummy account — valid for simulation only - const dummyAccount = new Account( - 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN', - '0' - ); - - const tx = new TransactionBuilder(dummyAccount, { + args: xdr.ScVal[], +): ReturnType { + const account = new Account(senderOrDummy, '0'); + return new TransactionBuilder(account, { fee: '100', networkPassphrase: config.networkPassphrase, }) @@ -33,11 +46,21 @@ async function simulateCall( contract: contractId, function: method, args, - }) + }), ) .setTimeout(30) .build(); +} +// Simulate a read-only contract call without a real account +async function simulateCall( + contractId: string, + method: string, + ...args: xdr.ScVal[] +): Promise { + // Use a zero-sequence dummy account — valid for simulation only + const dummyAccount = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; + const tx = buildTx(dummyAccount, contractId, method, args); const result = (await server.simulateTransaction( tx )) as SorobanRpc.Api.SimulateTransactionSuccessResponse; @@ -46,6 +69,79 @@ async function simulateCall( return scValToNative(result.result.retval); } +// --------------------------------------------------------------------------- +// Public: simulate a write transaction and return a structured preview. +// This does NOT submit anything on-chain. +// --------------------------------------------------------------------------- + +export interface SimulationPreview { + /** Estimated fee in stroops (1 XLM = 10_000_000 stroops). */ + feeStoops: string; + /** Decoded return value, if any. */ + result: unknown; + /** Whether the simulation succeeded. */ + success: true; +} + +/** + * Simulate a state-changing contract call for the given sender address. + * Returns a `SimulationPreview` on success or throws `SimulationError` on + * failure — so callers can distinguish simulation errors from real tx errors. + */ +export async function simulateWriteCall( + sender: string, + contractId: string, + method: string, + args: xdr.ScVal[], +): Promise { + const tx = buildTx(sender, contractId, method, args); + const raw = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(raw)) { + throw new SimulationError( + `Simulation error for ${method}: ${(raw as SorobanRpc.Api.SimulateTransactionErrorResponse).error}`, + raw, + ); + } + + const success = raw as SorobanRpc.Api.SimulateTransactionSuccessResponse; + return { + feeStoops: success.minResourceFee ?? '0', + result: success.result ? scValToNative(success.result.retval) : undefined, + success: true, + }; +} + +/** Simulate casting a vote. Call this before the real transaction to show the user a fee preview. */ +export async function simulateCastVote( + voter: string, + proposalId: number, + vote: string, +): Promise { + return simulateWriteCall(voter, config.governanceContractId, 'cast_vote', [ + nativeToScVal(voter, { type: 'address' }), + nativeToScVal(BigInt(proposalId), { type: 'u64' }), + nativeToScVal({ tag: vote, values: [] }, { type: 'enum' }), + ]); +} + +/** Simulate creating a proposal. Lets the proposer preview the fee before submitting. */ +export async function simulateCreateProposal( + proposer: string, + title: string, + description: string, + quorum: bigint, + duration: bigint, +): Promise { + return simulateWriteCall(proposer, config.governanceContractId, 'create_proposal', [ + nativeToScVal(proposer, { type: 'address' }), + nativeToScVal(title, { type: 'string' }), + nativeToScVal(description, { type: 'string' }), + nativeToScVal(quorum, { type: 'i128' }), + nativeToScVal(duration, { type: 'u64' }), + ]); +} + export async function fetchProposalCount(): Promise { const count = await simulateCall(config.governanceContractId, 'proposal_count'); return Number(count); diff --git a/frontend/src/components/ProposalDetail.tsx b/frontend/src/components/ProposalDetail.tsx index 51b1ccf..8103325 100644 --- a/frontend/src/components/ProposalDetail.tsx +++ b/frontend/src/components/ProposalDetail.tsx @@ -2,6 +2,7 @@ import type { Proposal } from '../types'; import { fetchHasVoted, fetchVoteRecord } from '../api'; import { useEffect, useState } from 'react'; import { formatTokenAmount } from '../utils'; +import { VoteSimulationPreview } from './VoteSimulationPreview'; interface Props { proposal: Proposal; @@ -84,6 +85,14 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose } : 'You have not voted on this proposal'} )} + + {/* Simulation preview — shown when wallet is connected and proposal is Active */} + {walletAddress && p.state === 'Active' && !hasVoted && ( + + )} ); diff --git a/frontend/src/components/VoteSimulationPreview.tsx b/frontend/src/components/VoteSimulationPreview.tsx new file mode 100644 index 0000000..66afc3e --- /dev/null +++ b/frontend/src/components/VoteSimulationPreview.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react'; +import type { SimulationPreview } from '../api'; +import { simulateCastVote, SimulationError } from '../api'; + +interface Props { + proposalId: number; + walletAddress: string; +} + +const VOTE_OPTIONS = ['Yes', 'No', 'Abstain'] as const; +type VoteOption = typeof VOTE_OPTIONS[number]; + +/** Formats stroops as XLM with 7 decimal places. */ +function stroopsToXlm(stroops: string): string { + const n = Number(stroops); + return isNaN(n) ? stroops : `${(n / 10_000_000).toFixed(7)} XLM`; +} + +/** + * VoteSimulationPreview + * + * Shows the user an estimated transaction fee before they submit a real vote. + * Simulation errors (e.g. already voted, proposal inactive) are displayed + * distinctly from network/connection errors. + */ +export function VoteSimulationPreview({ proposalId, walletAddress }: Props) { + const [selectedVote, setSelectedVote] = useState('Yes'); + const [preview, setPreview] = useState(null); + const [simError, setSimError] = useState<{ isContract: boolean; message: string } | null>(null); + const [loading, setLoading] = useState(false); + + async function handleSimulate() { + setLoading(true); + setPreview(null); + setSimError(null); + try { + const result = await simulateCastVote(walletAddress, proposalId, selectedVote); + setPreview(result); + } catch (err) { + if (err instanceof SimulationError) { + // Contract-level rejection (already voted, proposal not active, etc.) + setSimError({ isContract: true, message: err.message }); + } else { + // Network / RPC error + setSimError({ isContract: false, message: String(err) }); + } + } finally { + setLoading(false); + } + } + + return ( +
+

+ Preview transaction before voting +

+ +
+ {VOTE_OPTIONS.map(v => ( + + ))} + +
+ + {/* Success preview */} + {preview && ( +
+ ✓ Simulation successful +
+ Estimated fee: {stroopsToXlm(preview.feeStoops)} +
+
+ This is a dry-run — no transaction has been submitted. +
+
+ )} + + {/* Error: contract rejection (distinct styling) */} + {simError?.isContract && ( +
+ ⚠ Contract simulation rejected +

{simError.message}

+

+ This would fail on-chain. Check if you have already voted or if the proposal is still active. +

+
+ )} + + {/* Error: network / RPC issue */} + {simError && !simError.isContract && ( +
+ ✗ Simulation failed +

{simError.message}

+
+ )} +
+ ); +}