diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 97f0287..10cc922 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; 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}

+
+ )} +
+ ); +}