diff --git a/docs-site/docs/api-reference/soroban/send-transaction.md b/docs-site/docs/api-reference/soroban/send-transaction.md index c9f673b1..a897bc28 100644 --- a/docs-site/docs/api-reference/soroban/send-transaction.md +++ b/docs-site/docs/api-reference/soroban/send-transaction.md @@ -51,6 +51,17 @@ if (status.status === 'SUCCESS') { } ``` +The dashboard's contract invoker performs this status tracking automatically. It +polls `getTransaction` with a bounded attempt count and reports one of these +terminal states: `SUCCESS`, `FAILED`, `TIMEOUT`, or `EXPIRED`. `TIMEOUT` means +the RPC could not provide a final status within the polling window; `EXPIRED` +means every poll returned `NOT_FOUND`. A transaction hash is still retained in +the result and history so it can be checked independently. + +The invoker requires a configured Soroban RPC endpoint and a server that +implements `getTransaction`. Submission is disabled for mainnet in the UI, and +secret keys should only be used with a trusted local or testnet environment. + ```python import time from stellar_sdk import SorobanServer, Keypair diff --git a/src/components/dashboard/ContractInteraction.tsx b/src/components/dashboard/ContractInteraction.tsx index 07adc24a..4084e799 100644 --- a/src/components/dashboard/ContractInteraction.tsx +++ b/src/components/dashboard/ContractInteraction.tsx @@ -188,6 +188,7 @@ export default function ContractInteraction() { const [error, setError] = useState(''); const [simulationResult, setSimulationResult] = useState(null); const [invokeResult, setInvokeResult] = useState(null); + const [invokeStatus, setInvokeStatus] = useState(null); const [showMainnetReview, setShowMainnetReview] = useState(false); const { preferences, update } = usePreferences(); @@ -540,6 +541,7 @@ export default function ContractInteraction() { async function _doInvoke() { setError(''); setInvokeResult(null); + setInvokeStatus('PENDING'); setInvokeLoading(true); try { @@ -550,10 +552,19 @@ export default function ContractInteraction() { sourceAccount: form.sourceAccount || connectedAddress, secretKey: form.secretKey, network, + onStatus: (status) => setInvokeStatus(status), }); setInvokeResult(result); - await recordInteraction('invoke', 'success', result, null); + const resultStatus = String(result.status || '').toLowerCase(); + const interactionStatus = ['success', 'failed', 'timeout', 'expired'].includes(resultStatus) + ? resultStatus + : 'error'; + await recordInteraction('invoke', interactionStatus, result, result.error || null); + if (interactionStatus !== 'success') { + setError(result.error || `Invocation ${interactionStatus}`); + } } catch (err) { + setInvokeStatus(null); setError(err.message || 'Invocation failed'); await recordInteraction('invoke', 'error', null, err.message || 'Invocation failed'); } finally { @@ -931,7 +942,7 @@ export default function ContractInteraction() { disabled={simulateLoading || invokeLoading || anomalies.some(a => a.severity === 'error')} /> a.severity === 'error')} tone="secondary" @@ -968,6 +979,11 @@ export default function ContractInteraction() { )} + {invokeStatus && ( +
+ Transaction status: {invokeStatus} +
+ )} {invokeResult && } )} diff --git a/src/lib/contractInvoker.js b/src/lib/contractInvoker.js index 3dc70ce8..9694264d 100644 --- a/src/lib/contractInvoker.js +++ b/src/lib/contractInvoker.js @@ -80,6 +80,71 @@ function safeInvoke(target, methods, ...args) { return undefined; } +const TERMINAL_TRANSACTION_STATUSES = new Set(["SUCCESS", "FAILED"]); + +const wait = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export async function waitForTransaction( + server, + hash, + { maxAttempts = 30, pollIntervalMs = 1000, onStatus } = {}, +) { + if (!hash || typeof hash !== "string") { + throw new Error("Transaction hash is required"); + } + if (!server || typeof server.getTransaction !== "function") { + throw new Error("Soroban RPC does not support transaction status tracking"); + } + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new Error("Transaction polling attempts must be a positive integer"); + } + if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) { + throw new Error("Transaction polling interval must be a non-negative number"); + } + + let lastResponse = null; + let hadRpcError = false; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + const response = await server.getTransaction(hash); + lastResponse = response; + const status = response?.status || "PENDING"; + onStatus?.(status, response); + + if (status === "ERROR") { + return { ...response, hash, status: "FAILED" }; + } + if (TERMINAL_TRANSACTION_STATUSES.has(status)) { + return { ...response, hash }; + } + } catch { + hadRpcError = true; + } + + if (attempt < maxAttempts - 1) { + await wait(pollIntervalMs); + } + } + + const status = hadRpcError + ? "TIMEOUT" + : lastResponse?.status === "NOT_FOUND" + ? "EXPIRED" + : "TIMEOUT"; + + return { + ...(lastResponse || {}), + hash, + status, + error: + status === "EXPIRED" + ? "Transaction was not found before the polling window ended and may have expired" + : "Transaction status polling timed out", + }; +} + function readSwitchName(value) { const switchValue = safeInvoke(value, "switch"); @@ -455,6 +520,8 @@ export async function invokeContractFunction({ sourceAccount, secretKey, network = "testnet", + onStatus, + polling, }) { if (!isValidContractId(contractId)) { throw new Error("Invalid contract ID"); @@ -472,7 +539,15 @@ export async function invokeContractFunction({ throw new Error("Invalid secret key"); } + const networkConfig = NETWORKS?.[network]; + if (!networkConfig?.sorobanUrl || !networkConfig.passphrase) { + throw new Error(`Soroban is not configured for the ${network} network`); + } + const server = getSorobanServer(network); + if (typeof server?.getTransaction !== "function") { + throw new Error("Soroban RPC does not support transaction status tracking"); + } const horizon = getServer(network); const account = await horizon.loadAccount(sourceAccount); const contract = new StellarSdk.Contract(contractId); @@ -506,11 +581,18 @@ export async function invokeContractFunction({ const response = await server.sendTransaction(prepared); - return { - hash: response.hash, - status: response.status, - latestLedger: response.latestLedger, - }; + if (!response?.hash) { + throw new Error("Soroban RPC did not return a transaction hash"); + } + + const submittedStatus = response.status || "PENDING"; + onStatus?.(submittedStatus, response); + if (submittedStatus === "ERROR") { + onStatus?.("FAILED", response); + return { ...response, status: "FAILED" }; + } + + return waitForTransaction(server, response.hash, { ...polling, onStatus }); } export function normalizeContractValue(value) { diff --git a/src/lib/tests/contractInvoker.test.js b/src/lib/tests/contractInvoker.test.js index dd1d6d17..45999e1c 100644 --- a/src/lib/tests/contractInvoker.test.js +++ b/src/lib/tests/contractInvoker.test.js @@ -3,7 +3,8 @@ import * as StellarSdk from '@stellar/stellar-sdk'; import { parseContractWasm, invokeContractFunction, - normalizeContractValue + normalizeContractValue, + waitForTransaction, } from '../contractInvoker'; import { getSorobanServer, getServer, isValidContractId, isValidPublicKey } from '../stellar'; @@ -11,7 +12,10 @@ vi.mock('../stellar', () => ({ getSorobanServer: vi.fn(), getServer: vi.fn(), NETWORKS: { - testnet: { passphrase: 'Test SDF Network ; September 2015' } + testnet: { + sorobanUrl: 'https://soroban-testnet.stellar.org', + passphrase: 'Test SDF Network ; September 2015' + } }, isValidContractId: vi.fn(), isValidPublicKey: vi.fn(), @@ -37,6 +41,7 @@ describe('Contract Invoker Flows', () => { getLedgerEntries: vi.fn(), prepareTransaction: vi.fn(), sendTransaction: vi.fn(), + getTransaction: vi.fn(), }; mockHorizonServer = { @@ -82,7 +87,8 @@ describe('Contract Invoker Flows', () => { contractId: 'INVALID_ID', functionName: 'increment', sourceAccount: MOCK_PUBKEY, - secretKey: MOCK_SECRET + secretKey: MOCK_SECRET, + polling: { pollIntervalMs: 0 } })).rejects.toThrow('Invalid contract ID'); }); @@ -95,6 +101,16 @@ describe('Contract Invoker Flows', () => { })).rejects.toThrow('Function name is required'); }); + it('rejects networks without a configured Soroban endpoint', async () => { + await expect(invokeContractFunction({ + contractId: MOCK_CONTRACT_ID, + functionName: 'increment', + sourceAccount: MOCK_PUBKEY, + secretKey: MOCK_SECRET, + network: 'custom', + })).rejects.toThrow('Soroban is not configured for the custom network'); + }); + it('throws error for unsupported argument types', async () => { await expect(invokeContractFunction({ contractId: MOCK_CONTRACT_ID, @@ -114,6 +130,9 @@ describe('Contract Invoker Flows', () => { status: 'PENDING', latestLedger: 12345 }); + mockSorobanServer.getTransaction + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'SUCCESS', latestLedger: 12346 }); const args = [ { type: 'string', value: 'hello soroban' }, @@ -135,9 +154,55 @@ describe('Contract Invoker Flows', () => { expect(result).toEqual({ hash: 'mock-tx-hash', - status: 'PENDING', - latestLedger: 12345 + status: 'SUCCESS', + latestLedger: 12346 + }); + }); + + it('returns expired when the transaction remains absent through the polling window', async () => { + mockSorobanServer.getTransaction + .mockResolvedValueOnce({ status: 'NOT_FOUND' }); + + await expect(waitForTransaction(mockSorobanServer, 'mock-tx-hash', { + maxAttempts: 1, + pollIntervalMs: 0, + })).resolves.toMatchObject({ + hash: 'mock-tx-hash', + status: 'EXPIRED', + }); + }); + + it('returns timeout when status polling fails', async () => { + mockSorobanServer.getTransaction.mockRejectedValue(new Error('RPC unavailable')); + + await expect(waitForTransaction(mockSorobanServer, 'mock-tx-hash', { + maxAttempts: 1, + pollIntervalMs: 0, + })).resolves.toMatchObject({ + hash: 'mock-tx-hash', + status: 'TIMEOUT', + }); + }); + + it('normalizes an RPC submission error to a failed terminal result', async () => { + mockSorobanServer.prepareTransaction.mockResolvedValue({ sign: vi.fn() }); + mockSorobanServer.sendTransaction.mockResolvedValue({ + hash: 'mock-tx-hash', + status: 'ERROR', + errorResultXdr: 'mock-error', + }); + + await expect(invokeContractFunction({ + contractId: MOCK_CONTRACT_ID, + functionName: 'failing_call', + sourceAccount: MOCK_PUBKEY, + secretKey: MOCK_SECRET, + polling: { pollIntervalMs: 0 }, + })).resolves.toMatchObject({ + hash: 'mock-tx-hash', + status: 'FAILED', }); + expect(mockSorobanServer.getTransaction).not.toHaveBeenCalled(); }); it('handles Soroban RPC simulation/preparation failures seamlessly', async () => { diff --git a/src/lib/tests/network.test.ts b/src/lib/tests/network.test.ts index 16ea2bae..82c35c01 100644 --- a/src/lib/tests/network.test.ts +++ b/src/lib/tests/network.test.ts @@ -1,8 +1,37 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' +function setupMocks() { + vi.doMock('../storage', () => ({ + getStoredValue: vi.fn().mockResolvedValue(null), + setStoredValue: vi.fn(), + })) + vi.doMock('../../utils/stateSync', () => ({ + broadcastStateChange: vi.fn(), + onStateChange: vi.fn(), + syncState: vi.fn().mockResolvedValue(0), + loadSyncedState: vi.fn().mockReturnValue(null), + resolveStateConflict: vi.fn((local: unknown) => local), + getTabId: vi.fn().mockReturnValue('test-tab'), + })) + vi.doMock('../cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), + })) + vi.doMock('../requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, + })) +} + beforeEach(() => { // Ensure a clean module cache so the store initializer reads localStorage afresh vi.resetModules() + setupMocks() window.sessionStorage.clear() window.localStorage.clear() }) @@ -20,6 +49,7 @@ describe('Network persistence', () => { window.localStorage.setItem('stellar:selected-network', 'local') // re-import the module after setting localStorage vi.resetModules() + setupMocks() const { useStore } = await import('../store') expect(useStore.getState().network).toBe('local') }) diff --git a/src/lib/tests/store.test.ts b/src/lib/tests/store.test.ts index 29694633..32016a03 100644 --- a/src/lib/tests/store.test.ts +++ b/src/lib/tests/store.test.ts @@ -5,7 +5,34 @@ * independent. Resolve order now favours .ts before .js, matching Vite. */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../storage', () => ({ + getStoredValue: vi.fn().mockResolvedValue(null), + setStoredValue: vi.fn(), +})); +vi.mock('../../utils/stateSync', () => ({ + broadcastStateChange: vi.fn(), + onStateChange: vi.fn(), + syncState: vi.fn().mockResolvedValue(0), + loadSyncedState: vi.fn().mockReturnValue(null), + resolveStateConflict: vi.fn((local: unknown) => local), + getTabId: vi.fn().mockReturnValue('test-tab'), +})); +vi.mock('../cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +vi.mock('../requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, +})); + import { useStore } from '../store'; // Capture the baseline state once on first import so we can reset between tests. diff --git a/tests/integration/MultisigSetup.test.jsx b/tests/integration/MultisigSetup.test.jsx index 8204452d..8871bafa 100644 --- a/tests/integration/MultisigSetup.test.jsx +++ b/tests/integration/MultisigSetup.test.jsx @@ -18,13 +18,19 @@ vi.mock('../../src/utils/stateSync', () => ({ resolveStateConflict: vi.fn((local) => local), getTabId: vi.fn().mockReturnValue('test-tab'), })); - -const mockSuccess = vi.fn(); -const mockError = vi.fn(); -vi.mock('../../src/hooks/useNotifications', () => ({ - useNotifications: () => ({ success: mockSuccess, error: mockError, warning: vi.fn() }), +vi.mock('../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +vi.mock('../../src/lib/requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, })); -vi.mock('../../src/lib/stellar', async () => { const { Account } = await import('@stellar/stellar-sdk'); return { fetchAccount: vi.fn().mockResolvedValue( diff --git a/tests/integration/SessionManager.test.jsx b/tests/integration/SessionManager.test.jsx index 57b3035c..2d419c29 100644 --- a/tests/integration/SessionManager.test.jsx +++ b/tests/integration/SessionManager.test.jsx @@ -15,6 +15,19 @@ vi.mock('../../src/utils/stateSync', () => ({ resolveStateConflict: vi.fn((local) => local), getTabId: vi.fn().mockReturnValue('test-tab'), })); +vi.mock('../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +vi.mock('../../src/lib/requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, +})); const mockSuccess = vi.fn(); const mockError = vi.fn(); diff --git a/tests/integration/SignatureCollector.test.jsx b/tests/integration/SignatureCollector.test.jsx index 42e7a089..e65f8244 100644 --- a/tests/integration/SignatureCollector.test.jsx +++ b/tests/integration/SignatureCollector.test.jsx @@ -17,6 +17,19 @@ vi.mock('../../src/utils/stateSync', () => ({ resolveStateConflict: vi.fn((local) => local), getTabId: vi.fn().mockReturnValue('test-tab'), })); +vi.mock('../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +vi.mock('../../src/lib/requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, +})); const mockSuccess = vi.fn(); const mockWarning = vi.fn(); diff --git a/tests/unit/hooks/useStorageQuotaAlerts.test.tsx b/tests/unit/hooks/useStorageQuotaAlerts.test.tsx index 5e9b5b7f..6ad891a8 100644 --- a/tests/unit/hooks/useStorageQuotaAlerts.test.tsx +++ b/tests/unit/hooks/useStorageQuotaAlerts.test.tsx @@ -4,6 +4,34 @@ import { notifyQuotaExceeded, _resetQuotaListeners } from '../../../src/lib/stor import { useStore } from '../../../src/lib/store'; import { useStorageQuotaAlerts } from '../../../src/hooks/useStorageQuotaAlerts'; +// store.ts now imports cacheInit and requestCancellation — mock both so the +// cache stack does not load in jsdom. +vi.mock('../../../src/lib/storage', () => ({ + getStoredValue: vi.fn().mockResolvedValue(null), + setStoredValue: vi.fn(), +})); +vi.mock('../../../src/utils/stateSync', () => ({ + broadcastStateChange: vi.fn(), + onStateChange: vi.fn(), + syncState: vi.fn().mockResolvedValue(0), + loadSyncedState: vi.fn().mockReturnValue(null), + resolveStateConflict: vi.fn((local: unknown) => local), + getTabId: vi.fn().mockReturnValue('test-tab'), +})); +vi.mock('../../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +vi.mock('../../../src/lib/requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, +})); + describe('useStorageQuotaAlerts', () => { beforeEach(() => { useStore.setState({ notifications: [], notificationHistory: [] }); diff --git a/tests/unit/lib/store.networkCancellation.test.ts b/tests/unit/lib/store.networkCancellation.test.ts index cfddd568..8d3d59fa 100644 --- a/tests/unit/lib/store.networkCancellation.test.ts +++ b/tests/unit/lib/store.networkCancellation.test.ts @@ -1,7 +1,27 @@ // tests/unit/lib/store.networkCancellation.test.ts // Issue #745 — switching network must cancel in-flight Horizon reads and leave no // loading flag stuck on, since the cancelled requests' own handlers will not fire. -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../../src/lib/storage', () => ({ + getStoredValue: vi.fn().mockResolvedValue(null), + setStoredValue: vi.fn(), +})); +vi.mock('../../../src/utils/stateSync', () => ({ + broadcastStateChange: vi.fn(), + onStateChange: vi.fn(), + syncState: vi.fn().mockResolvedValue(0), + loadSyncedState: vi.fn().mockReturnValue(null), + resolveStateConflict: vi.fn((local: unknown) => local), + getTabId: vi.fn().mockReturnValue('test-tab'), +})); +vi.mock('../../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); + import { useStore } from '../../../src/lib/store'; import { accountRequests, AccountLanes } from '../../../src/lib/requestCancellation'; diff --git a/tests/unit/lib/store.test.js b/tests/unit/lib/store.test.js index 6d8f9591..cc153d36 100644 --- a/tests/unit/lib/store.test.js +++ b/tests/unit/lib/store.test.js @@ -12,6 +12,21 @@ vi.mock('../../../src/utils/stateSync', () => ({ resolveStateConflict: vi.fn((local) => local), getTabId: vi.fn().mockReturnValue('test-tab'), })); +vi.mock('../../../src/lib/cacheInit', () => ({ + handleNetworkSwitch: vi.fn(), + initCache: vi.fn().mockResolvedValue(undefined), + handleTransactionSuccess: vi.fn().mockResolvedValue(undefined), + _resetCacheInit: vi.fn(), +})); +// requestCancellation is safe to use real — no side effects at import time. +// But mock it so tests run fast and don't depend on AbortController polyfills. +vi.mock('../../../src/lib/requestCancellation', () => ({ + accountRequests: { abortAll: vi.fn(), begin: vi.fn(() => ({ active: true, commit: vi.fn(() => true), abort: vi.fn() })) }, + AccountLanes: { Connect: 'account:connect', Offers: 'account:offers', CreationDate: 'account:creation-date' }, + isCancellation: vi.fn(() => false), + isStaleRequestError: vi.fn(() => false), + StaleRequestError: class StaleRequestError extends Error {}, +})); import { useStore } from '../../../src/lib/store';