Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 108 additions & 12 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
// 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<TransactionBuilder['build']> {
const account = new Account(senderOrDummy, '0');
return new TransactionBuilder(account, {
fee: '100',
networkPassphrase: config.networkPassphrase,
})
Expand All @@ -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<unknown> {
// 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;
Expand All @@ -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<SimulationPreview> {
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<SimulationPreview> {
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<SimulationPreview> {
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<number> {
const count = await simulateCall(config.governanceContractId, 'proposal_count');
return Number(count);
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/ProposalDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +85,14 @@ export function ProposalDetail({ proposal: p, decimals, walletAddress, onClose }
: 'You have not voted on this proposal'}
</div>
)}

{/* Simulation preview — shown when wallet is connected and proposal is Active */}
{walletAddress && p.state === 'Active' && !hasVoted && (
<VoteSimulationPreview
proposalId={Number(p.id)}
walletAddress={walletAddress}
/>
)}
</div>
</div>
);
Expand Down
145 changes: 145 additions & 0 deletions frontend/src/components/VoteSimulationPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<VoteOption>('Yes');
const [preview, setPreview] = useState<SimulationPreview | null>(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 (
<div
style={{
marginTop: '1rem',
padding: '1rem',
background: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
}}
aria-label="Vote simulation preview"
>
<h4 style={{ margin: '0 0 0.75rem', fontSize: '0.875rem', color: '#475569' }}>
Preview transaction before voting
</h4>

<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
{VOTE_OPTIONS.map(v => (
<label key={v} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer', fontSize: '0.875rem' }}>
<input
type="radio"
name={`sim-vote-${proposalId}`}
value={v}
checked={selectedVote === v}
onChange={() => setSelectedVote(v)}
/>
{v}
</label>
))}
<button
onClick={handleSimulate}
disabled={loading}
style={{
marginLeft: 'auto',
background: '#3b82f6',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '0.35rem 0.75rem',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
opacity: loading ? 0.7 : 1,
}}
aria-busy={loading}
>
{loading ? 'Simulating…' : 'Simulate'}
</button>
</div>

{/* Success preview */}
{preview && (
<div
style={{ background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 6, padding: '0.75rem', fontSize: '0.875rem' }}
role="status"
aria-live="polite"
>
<strong style={{ color: '#15803d' }}>✓ Simulation successful</strong>
<div style={{ marginTop: '0.5rem', color: '#166534' }}>
Estimated fee: <strong>{stroopsToXlm(preview.feeStoops)}</strong>
</div>
<div style={{ marginTop: '0.25rem', color: '#6b7280', fontSize: '0.75rem' }}>
This is a dry-run — no transaction has been submitted.
</div>
</div>
)}

{/* Error: contract rejection (distinct styling) */}
{simError?.isContract && (
<div
style={{ background: '#fef9c3', border: '1px solid #fde047', borderRadius: 6, padding: '0.75rem', fontSize: '0.875rem' }}
role="alert"
aria-live="assertive"
>
<strong style={{ color: '#854d0e' }}>⚠ Contract simulation rejected</strong>
<p style={{ margin: '0.25rem 0 0', color: '#713f12', fontSize: '0.8rem' }}>{simError.message}</p>
<p style={{ margin: '0.25rem 0 0', color: '#92400e', fontSize: '0.75rem' }}>
This would fail on-chain. Check if you have already voted or if the proposal is still active.
</p>
</div>
)}

{/* Error: network / RPC issue */}
{simError && !simError.isContract && (
<div
style={{ background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: 6, padding: '0.75rem', fontSize: '0.875rem' }}
role="alert"
aria-live="assertive"
>
<strong style={{ color: '#dc2626' }}>✗ Simulation failed</strong>
<p style={{ margin: '0.25rem 0 0', color: '#991b1b', fontSize: '0.8rem' }}>{simError.message}</p>
</div>
)}
</div>
);
}