Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs-site/docs/api-reference/soroban/send-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions src/components/dashboard/ContractInteraction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -540,6 +541,7 @@ export default function ContractInteraction() {
async function _doInvoke() {
setError('');
setInvokeResult(null);
setInvokeStatus('PENDING');
setInvokeLoading(true);

try {
Expand All @@ -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 {
Expand Down Expand Up @@ -931,7 +942,7 @@ export default function ContractInteraction() {
disabled={simulateLoading || invokeLoading || anomalies.some(a => a.severity === 'error')}
/>
<ActionButton
label={invokeLoading ? "Invoking..." : isMainnet ? "Invoke on Mainnet…" : "Invoke"}
label={invokeLoading ? `${invokeStatus || "PENDING"}...` : isMainnet ? "Invoke on Mainnet…" : "Invoke"}
onClick={handleInvoke}
disabled={invokeLoading || simulateLoading || anomalies.some(a => a.severity === 'error')}
tone="secondary"
Expand Down Expand Up @@ -968,6 +979,11 @@ export default function ContractInteraction() {
</div>
)}

{invokeStatus && (
<div style={{ fontSize: "12px", color: invokeStatus === "SUCCESS" ? "var(--green)" : "var(--text-secondary)" }}>
Transaction status: {invokeStatus}
</div>
)}
{invokeResult && <ResultBlock label="Invocation Result" data={invokeResult} />}
</>
)}
Expand Down
92 changes: 87 additions & 5 deletions src/lib/contractInvoker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -455,6 +520,8 @@ export async function invokeContractFunction({
sourceAccount,
secretKey,
network = "testnet",
onStatus,
polling,
}) {
if (!isValidContractId(contractId)) {
throw new Error("Invalid contract ID");
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
75 changes: 70 additions & 5 deletions src/lib/tests/contractInvoker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import * as StellarSdk from '@stellar/stellar-sdk';
import {
parseContractWasm,
invokeContractFunction,
normalizeContractValue
normalizeContractValue,
waitForTransaction,
} from '../contractInvoker';
import { getSorobanServer, getServer, isValidContractId, isValidPublicKey } from '../stellar';

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(),
Expand All @@ -37,6 +41,7 @@ describe('Contract Invoker Flows', () => {
getLedgerEntries: vi.fn(),
prepareTransaction: vi.fn(),
sendTransaction: vi.fn(),
getTransaction: vi.fn(),
};

mockHorizonServer = {
Expand Down Expand Up @@ -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');
});

Expand All @@ -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,
Expand All @@ -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' },
Expand All @@ -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 () => {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/tests/network.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Expand All @@ -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')
})
Expand Down
Loading
Loading