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(); +}