From 3966850ad3533bb6fc54f2c9da3fa2e5e9a326f9 Mon Sep 17 00:00:00 2001 From: Ryjen1 Date: Wed, 27 May 2026 11:29:01 +0100 Subject: [PATCH] feat: add Stellar transaction simulator (dry-run) before submit Wire simulateTransaction against Soroban RPC into StellarSend so users see predicted fees, return values, and contract events before signing. Simulation failures disable the Send button with decoded contract errors; network errors fall back to letting the user try anyway. Closes #24 --- e2e/stellar-simulate.spec.ts | 112 ++++++++++++++++++++ package.json | 1 + playwright.config.ts | 14 +++ pnpm-lock.yaml | 60 ++++++++--- src/components/SimulationCard.tsx | 96 +++++++++++++++++ src/components/StellarSend.tsx | 71 +++++++++++-- src/hooks/useSimulateTransaction.ts | 77 ++++++++++++++ src/lib/errors.ts | 100 ++++++++++++++++++ src/lib/soroban.ts | 157 ++++++++++++++++++++++++++++ 9 files changed, 665 insertions(+), 23 deletions(-) create mode 100644 e2e/stellar-simulate.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/components/SimulationCard.tsx create mode 100644 src/hooks/useSimulateTransaction.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/soroban.ts diff --git a/e2e/stellar-simulate.spec.ts b/e2e/stellar-simulate.spec.ts new file mode 100644 index 0000000..7e3ccb2 --- /dev/null +++ b/e2e/stellar-simulate.spec.ts @@ -0,0 +1,112 @@ +import { test, expect } from '@playwright/test'; + +const MOCK_ADDRESS = 'GBCXSLSJYWRNPXL4R56WEMH5HVXL6IDDYF6YQVQ5JPR2LQ1Y2MJ3R5VM'; + +const VALID_META_ADDRESS = 'st:xlm:04' + 'a'.repeat(64) + 'b'.repeat(64); + +test.describe('Stellar Send — Transaction Simulator', () => { + test('shows disconnected state when wallet is not connected', async ({ page }) => { + await page.goto('/send'); + await expect(page.locator('text=Connect your Freighter wallet')).toBeVisible(); + await expect(page.locator('text=Send Privately')).not.toBeVisible(); + }); + + test('simulation card appears and disables send on failure', async ({ page }) => { + await page.addInitScript((mockAddr) => { + (window as unknown as Record).freighter = true; + + const originalAddEventListener = window.addEventListener.bind(window); + window.addEventListener = ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) => { + if (type === 'message') { + const wrappedListener = (event: MessageEvent) => { + if (event.data && typeof event.data === 'object' && 'type' in event.data) { + const data = event.data as Record; + let response: Record | null = null; + + if (data.type === 'FREIGHTER_GET_PUBLIC_KEY') { + response = { type: 'FREIGHTER_GET_PUBLIC_KEY_RESPONSE', publicKey: mockAddr }; + } else if (data.type === 'FREIGHTER_IS_CONNECTED') { + response = { type: 'FREIGHTER_IS_CONNECTED_RESPONSE', isConnected: true }; + } else if (data.type === 'FREIGHTER_REQUEST_ACCESS') { + response = { type: 'FREIGHTER_REQUEST_ACCESS_RESPONSE', granted: true }; + } else if (data.type === 'FREIGHTER_SIGN_TRANSACTION') { + response = { + type: 'FREIGHTER_SIGN_TRANSACTION_RESPONSE', + signedTx: data.xdr || 'mock', + }; + } + + if (response) { + setTimeout(() => { + window.postMessage(response, '*'); + }, 10); + } + } + + if (typeof listener === 'function') { + listener(event); + } else if (listener && typeof listener.handleEvent === 'function') { + listener.handleEvent(event); + } + }; + return originalAddEventListener(type, wrappedListener as EventListener, options); + } + return originalAddEventListener(type, listener, options); + }; + }, MOCK_ADDRESS); + + await page.route('**/accounts/**', async (route) => { + const url = route.request().url(); + if (url.includes(MOCK_ADDRESS)) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + account_id: MOCK_ADDRESS, + sequence: '123456789', + balances: [{ asset_type: 'native', balance: '100.0000000' }], + subentry_count: 0, + }), + }); + } else { + await route.fulfill({ status: 404, body: '{"error":"not found"}' }); + } + }); + + await page.route('**/soroban-testnet.stellar.org/**', async (route) => { + const body = route.request().postData(); + if (body && body.includes('simulateTransaction')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + error: 'ContractError(2): InvalidStealthAddress', + events: [], + latestLedger: 1000, + }, + }), + }); + } else { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }), + }); + } + }); + + await page.goto('/send'); + + const simCard = page.locator('text=Simulation Failed'); + const sendButton = page.locator('button', { hasText: 'Send Privately' }); + + await expect(sendButton.or(simCard)).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/package.json b/package.json index 886cfb3..d80b0b8 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "@playwright/test": "^1.60.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.5.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..91c65a7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30000, + webServer: { + command: 'pnpm dev', + port: 5173, + reuseExistingServer: true, + }, + use: { + baseURL: 'http://localhost:5173', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3b5c05..7c443f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: '@commitlint/config-conventional': specifier: ^19.0.0 version: 19.8.1 + '@playwright/test': + specifier: ^1.60.0 + version: 1.60.0 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -732,6 +735,11 @@ packages: resolution: {integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==} deprecated: 'The package is now available as "qr": npm install qr' + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@rainbow-me/rainbowkit@2.2.10': resolution: {integrity: sha512-8+E4die1A2ovN9t3lWxWnwqTGEdFqThXDQRj+E4eDKuUKyymYD+66Gzm6S9yfg8E95c6hmGlavGUfYPtl1EagA==} engines: {node: '>=12.4'} @@ -869,79 +877,66 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -2593,6 +2588,11 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3387,6 +3387,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -4132,6 +4142,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true valibot@1.3.1: @@ -5277,7 +5288,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.13 - debug: 4.3.4 + debug: 4.4.3 pony-cause: 2.1.11 semver: 7.7.4 uuid: 9.0.1 @@ -5291,7 +5302,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.13 - debug: 4.3.4 + debug: 4.4.3 pony-cause: 2.1.11 semver: 7.7.4 uuid: 9.0.1 @@ -5362,6 +5373,10 @@ snapshots: '@paulmillr/qr@0.2.1': {} + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@rainbow-me/rainbowkit@2.2.10(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(wagmi@2.19.5(@react-native-async-storage/async-storage@1.24.0(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6)))(@tanstack/query-core@5.99.0)(@tanstack/react-query@5.99.0(react@19.2.5))(@types/react@19.2.14)(bufferutil@4.1.0)(react-native@0.85.1(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.5)(utf-8-validate@6.0.6))(react@19.2.5)(typescript@5.9.3)(utf-8-validate@6.0.6)(viem@2.47.17(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@6.0.6)(zod@4.3.6))(zod@4.3.6))': dependencies: '@tanstack/react-query': 5.99.0(react@19.2.5) @@ -8112,6 +8127,9 @@ snapshots: fresh@0.5.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8900,8 +8918,8 @@ snapshots: '@adraffy/ens-normalize': 1.11.1 '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 abitype: 1.0.8(typescript@5.9.3)(zod@4.3.6) eventemitter3: 5.0.1 optionalDependencies: @@ -9012,6 +9030,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} pony-cause@2.1.11: {} diff --git a/src/components/SimulationCard.tsx b/src/components/SimulationCard.tsx new file mode 100644 index 0000000..a9fd537 --- /dev/null +++ b/src/components/SimulationCard.tsx @@ -0,0 +1,96 @@ +import type { SimulationResult } from '@/lib/soroban'; + +interface SimulationCardProps { + result: SimulationResult; + isSimulating: boolean; +} + +export function SimulationCard({ result, isSimulating }: SimulationCardProps) { + if (isSimulating) { + return ( +
+ + + Simulating transaction... + +
+ ); + } + + if (!result.ok) { + return ( +
+
+ + + Simulation Failed + +
+

{result.error}

+ {result.isNetworkError && ( +

+ Network issue — you can still try sending. +

+ )} +
+ ); + } + + return ( +
+
+ + + Predicted Result + + + (simulation only) + +
+ +
+
+ + Predicted Fee + + + {formatStroops(result.predictedFeeStroops)} + +
+ + {result.returnValue && ( +
+ + Return Value + + + {result.returnValue} + +
+ )} + + {result.eventCount > 0 && ( +
+ + Contract Events + + + {result.eventCount} announcement{result.eventCount !== 1 ? 's' : ''} will be emitted + +
+ )} +
+ +

+ This is a simulation. Actual results may differ when ledger state changes. +

+
+ ); +} + +function formatStroops(stroops: string): string { + const val = parseInt(stroops, 10); + if (isNaN(val)) return `${stroops} stroops`; + const xlm = val / 10_000_000; + return `${xlm.toFixed(7)} XLM (${stroops} stroops)`; +} diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index 6626d1f..34e5296 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { TransactionBuilder, Account, @@ -18,6 +18,10 @@ import { useStellarWallet } from '@/context/StellarWalletContext'; import { stellarTxUrl, stellarAddrUrl } from '@/lib/explorer'; import { STELLAR_NETWORK } from '@/config'; import { CopyButton } from '@/components/CopyButton'; +import { useSimulateTransaction } from '@/hooks/useSimulateTransaction'; +import { SimulationCard } from '@/components/SimulationCard'; +import { buildAnnounceTransaction } from '@/lib/soroban'; +import { decodeSimulationError } from '@/lib/errors'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; @@ -35,6 +39,50 @@ export function StellarSend() { const [txHash, setTxHash] = useState(null); const [isSuccess, setIsSuccess] = useState(false); + const stealthPreview = useMemo(() => { + if (!recipient.startsWith('st:xlm:') || !amount || parseFloat(amount) <= 0) return null; + try { + const decoded = decodeStealthMetaAddress(recipient); + return generateStealthAddress(decoded.spendingPubKey, decoded.viewingPubKey); + } catch { + return null; + } + }, [recipient, amount]); + + const buildTx = useMemo(() => { + if (!address || !stealthPreview) return null; + return () => + buildAnnounceTransaction( + address, + stealthPreview.stealthAddress, + stealthPreview.ephemeralPubKey, + stealthPreview.viewTag, + SCHEME_ID, + ANNOUNCER_CONTRACT, + ); + }, [address, stealthPreview]); + + const { + result: simResult, + isSimulating, + simulate, + reset: resetSim, + } = useSimulateTransaction(buildTx, { + debounceMs: 500, + enabled: !!address && !!stealthPreview && !isSuccess, + }); + + useEffect(() => { + if (stealthPreview && address && !isSuccess) { + simulate(); + } else { + resetSim(); + } + }, [stealthPreview, address, isSuccess, simulate, resetSim]); + + const simError = simResult && !simResult.ok ? decodeSimulationError(simResult.error) : null; + const simBlocksSend = simResult !== null && !simResult.ok && !simResult.isNetworkError; + const handleSend = useCallback(async () => { if (!address) { setError('Wallet not connected'); @@ -109,7 +157,6 @@ export function StellarSend() { setTxHash(submitData.hash); - // Announce via Soroban (best-effort) try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); const soroban = new rpcMod.Server(STELLAR_NETWORK.rpcUrl); @@ -119,7 +166,10 @@ export function StellarSend() { const freshData = await freshRes.json(); const freshAccount = new Account(address, freshData.sequence); - const announceTx = new TransactionBuilder(freshAccount, { fee: '100', networkPassphrase }) + const announceTx = new TransactionBuilder(freshAccount, { + fee: '100', + networkPassphrase, + }) .addOperation( announcerContract.call( 'announce', @@ -165,6 +215,7 @@ export function StellarSend() { setTxHash(null); setIsSuccess(false); setError(''); + resetSim(); }; const handlePaste = async () => { @@ -253,7 +304,11 @@ export function StellarSend() { Network fee - 100 stroops + + {simResult?.ok + ? `${(parseInt(simResult.predictedFeeStroops, 10) / 10_000_000).toFixed(7)} XLM` + : '100 stroops'} +
@@ -263,14 +318,18 @@ export function StellarSend() {
+ {simResult && } + + {simError &&

{simError}

} + {error &&

{error}

} )} diff --git a/src/hooks/useSimulateTransaction.ts b/src/hooks/useSimulateTransaction.ts new file mode 100644 index 0000000..7bf84c8 --- /dev/null +++ b/src/hooks/useSimulateTransaction.ts @@ -0,0 +1,77 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { SimulationResult } from '@/lib/soroban'; + +interface UseSimulateTransactionOptions { + debounceMs?: number; + enabled?: boolean; +} + +interface UseSimulateTransactionReturn { + result: SimulationResult | null; + isSimulating: boolean; + simulate: () => void; + reset: () => void; +} + +export function useSimulateTransaction( + buildTx: (() => Promise) | null, + opts: UseSimulateTransactionOptions = {}, +): UseSimulateTransactionReturn { + const { debounceMs = 500, enabled = true } = opts; + const [result, setResult] = useState(null); + const [isSimulating, setIsSimulating] = useState(false); + const debounceRef = useRef | null>(null); + const abortRef = useRef(0); + const sorobanRef = useRef(null); + + const reset = useCallback(() => { + setResult(null); + setIsSimulating(false); + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current++; + }, []); + + const runSimulation = useCallback(async () => { + if (!buildTx || !enabled) return; + + const simId = ++abortRef.current; + setIsSimulating(true); + + try { + if (!sorobanRef.current) { + sorobanRef.current = await import('@/lib/soroban'); + } + + const tx = await buildTx(); + if (simId !== abortRef.current) return; + const simResult = await sorobanRef.current.simulateStellarTransaction(tx); + if (simId !== abortRef.current) return; + setResult(simResult); + } catch (err) { + if (simId !== abortRef.current) return; + setResult({ + ok: false, + error: err instanceof Error ? err.message : 'Simulation failed', + isNetworkError: false, + }); + } finally { + if (simId === abortRef.current) { + setIsSimulating(false); + } + } + }, [buildTx, enabled]); + + const simulate = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(runSimulation, debounceMs); + }, [runSimulation, debounceMs]); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + abortRef.current++; + }; + }, []); + + return { result, isSimulating, simulate, reset }; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..8ee328b --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,100 @@ +export interface ContractErrorDef { + code: number; + name: string; + message: string; +} + +type ErrorRegistry = Record>; + +const registry: ErrorRegistry = { + CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL: { + 1: { + code: 1, + name: 'AlreadyAnnounced', + message: 'This stealth address has already been announced.', + }, + 2: { + code: 2, + name: 'InvalidStealthAddress', + message: 'The stealth address is invalid or malformed.', + }, + 3: { + code: 3, + name: 'InvalidScheme', + message: 'Unsupported stealth address scheme.', + }, + 4: { + code: 4, + name: 'Unauthorized', + message: 'Caller is not authorized to announce.', + }, + }, + CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5: { + 1: { + code: 1, + name: 'AlreadyRegistered', + message: 'This meta-address is already registered on-chain.', + }, + 2: { + code: 2, + name: 'InvalidMetaAddress', + message: 'The meta-address is invalid or has incorrect length.', + }, + 3: { + code: 3, + name: 'InvalidScheme', + message: 'Unsupported stealth address scheme.', + }, + 4: { + code: 4, + name: 'Unauthorized', + message: 'Caller is not authorized to register keys.', + }, + }, +}; + +export function decodeContractError(contractAddress: string, errorString: string): string | null { + const contractErrors = registry[contractAddress]; + if (!contractErrors) return null; + + const codeMatch = errorString.match(/ContractError\((\d+)\)/); + if (codeMatch) { + const code = parseInt(codeMatch[1], 10); + const def = contractErrors[code]; + if (def) return `${def.name}: ${def.message}`; + } + + for (const def of Object.values(contractErrors)) { + if (errorString.includes(def.name)) { + return `${def.name}: ${def.message}`; + } + } + + return null; +} + +export function decodeSimulationError(errorString: string, contractAddress?: string): string { + if (contractAddress) { + const decoded = decodeContractError(contractAddress, errorString); + if (decoded) return decoded; + } + + if (errorString.includes('InsufficientBalance') || errorString.includes('insufficient_balance')) { + return 'Insufficient balance to complete this transaction.'; + } + if (errorString.includes('AccountNotFound') || errorString.includes('account_not_found')) { + return 'Account not found on the network.'; + } + if (errorString.includes('ResourceLimitExceeded')) { + return 'Transaction exceeds resource limits. Try reducing the operation complexity.'; + } + + return errorString; +} + +export function registerContractErrors( + contractAddress: string, + errors: Record, +) { + registry[contractAddress] = errors; +} diff --git a/src/lib/soroban.ts b/src/lib/soroban.ts new file mode 100644 index 0000000..393b848 --- /dev/null +++ b/src/lib/soroban.ts @@ -0,0 +1,157 @@ +import { TransactionBuilder, Account, xdr } from '@stellar/stellar-sdk'; +import { STELLAR_NETWORK } from '@/config'; + +export interface SimulationSuccess { + ok: true; + minResourceFee: string; + predictedFeeStroops: string; + returnValue: string | null; + eventCount: number; + transactionData: string; + latestLedger: number; +} + +export interface SimulationFailure { + ok: false; + error: string; + isNetworkError: boolean; +} + +export type SimulationResult = SimulationSuccess | SimulationFailure; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let rpcModule: any = null; + +async function getRpcModule() { + if (!rpcModule) { + const sdk = await import('@stellar/stellar-sdk'); + rpcModule = sdk.rpc; + } + return rpcModule; +} + +export async function getSorobanServer() { + const rpcMod = await getRpcModule(); + return new rpcMod.Server(STELLAR_NETWORK.rpcUrl); +} + +function formatScVal(val: xdr.ScVal): string { + try { + const name = val.switch().name; + switch (name) { + case 'scvBool': + return val.b() ? 'true' : 'false'; + case 'scvVoid': + return 'void'; + case 'scvU32': + return val.u32().toString(); + case 'scvI32': + return val.i32().toString(); + case 'scvU64': + return val.u64().toString(); + case 'scvI64': + return val.i64().toString(); + case 'scvU128': + return val.u128().toString(); + case 'scvI128': + return val.i128().toString(); + case 'scvU256': + return val.u256().toString(); + case 'scvI256': + return val.i256().toString(); + case 'scvBytes': + return Buffer.from(val.bytes()).toString('hex'); + case 'scvString': + return val.str().toString(); + case 'scvSymbol': + return val.sym().toString(); + default: + return val.toXDR('base64'); + } + } catch { + return val.toXDR('base64'); + } +} + +export async function simulateStellarTransaction( + tx: import('@stellar/stellar-sdk').Transaction, +): Promise { + try { + const rpcMod = await getRpcModule(); + const soroban = await getSorobanServer(); + + const simulated = await soroban.simulateTransaction(tx); + + if (rpcMod.Api.isSimulationError(simulated)) { + return { + ok: false, + error: simulated.error, + isNetworkError: false, + }; + } + + if (rpcMod.Api.isSimulationSuccess(simulated) || rpcMod.Api.isSimulationRestore(simulated)) { + const resultVal = simulated.result?.retval ?? null; + return { + ok: true, + minResourceFee: simulated.minResourceFee, + predictedFeeStroops: simulated.minResourceFee, + returnValue: resultVal ? formatScVal(resultVal) : null, + eventCount: simulated.events?.length ?? 0, + transactionData: simulated.transactionData.build().toXDR('base64'), + latestLedger: simulated.latestLedger, + }; + } + + return { + ok: false, + error: 'Unexpected simulation response', + isNetworkError: false, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Simulation failed'; + const isNetworkError = + message.includes('fetch') || + message.includes('network') || + message.includes('ECONNREFUSED') || + message.includes('timeout'); + return { + ok: false, + error: message, + isNetworkError, + }; + } +} + +export async function buildAnnounceTransaction( + senderAddress: string, + stealthAddress: string, + ephemeralPubKey: Uint8Array, + viewTag: number, + schemeId: number, + contractAddress: string, +): Promise { + const { Contract, nativeToScVal, Address } = await import('@stellar/stellar-sdk'); + const horizonUrl = STELLAR_NETWORK.horizonUrl; + const networkPassphrase = STELLAR_NETWORK.networkPassphrase; + + const accountRes = await fetch(`${horizonUrl}/accounts/${senderAddress}`); + if (!accountRes.ok) throw new Error('Failed to load sender account'); + const accountData = await accountRes.json(); + const sourceAccount = new Account(senderAddress, accountData.sequence); + + const contract = new Contract(contractAddress); + + return new TransactionBuilder(sourceAccount, { fee: '100', networkPassphrase }) + .addOperation( + contract.call( + 'announce', + nativeToScVal(schemeId, { type: 'u32' }), + new Address(stealthAddress).toScVal(), + xdr.ScVal.scvBytes(Buffer.from(ephemeralPubKey)), + xdr.ScVal.scvBytes(Buffer.from([viewTag])), + ), + ) + .setTimeout(30) + .build(); +}