diff --git a/app/src/app/deposit/page.tsx b/app/src/app/deposit/page.tsx index fa2d101..a151aba 100644 --- a/app/src/app/deposit/page.tsx +++ b/app/src/app/deposit/page.tsx @@ -9,11 +9,14 @@ import { beginSubmission, markAwaitingSignature, markSubmitted, + markPending, markConfirmed, markFailed, + markExpired, findActiveIntent, type PaymentIntent, } from '@/lib/paymentIntent'; +import { waitForTransaction, TransactionTimedOutError } from '@/services/rpc'; const TIERS = [ { id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 }, @@ -24,7 +27,7 @@ const TIERS = [ type TierId = typeof TIERS[number]['id']; type Step = 1 | 2 | 3 | 4; -type TxStatus = 'pending' | 'confirmed' | 'failed'; +type TxStatus = 'pending' | 'confirmed' | 'failed' | 'expired'; function lockExpiry(lockMonths: number): string { if (lockMonths === 0) return 'No lock'; @@ -33,6 +36,32 @@ function lockExpiry(lockMonths: number): string { return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); } +/** + * TODO(#141 follow-up): stands in for `sdk.deposit({ tier: tier.label, + * amount, idempotencyKey })` (sdks/typescript's YieldLadder, built in + * #139) until the app depends on the SDK directly. That wiring is blocked + * here on adding `@yieldladder/sdk`/`@stellar/stellar-sdk` as a real + * `app/package.json` dependency, which needs a `pnpm install` to + * regenerate `app/pnpm-lock.yaml` correctly — this environment can't + * safely run that (see the memory-constrained-box note in the repo's + * contribution history). Everything downstream of the hash this returns + * (`waitForTransaction` below) is real and already wired for the day this + * returns a genuine submission hash instead. + * + * Format-valid (64 hex chars, like a real Stellar transaction hash) so it + * exercises the real confirmation poller end-to-end against the live RPC + * endpoint rather than failing fast on obviously-malformed input. + */ +async function placeholderSubmissionHash(idempotencyKey: string): Promise { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(idempotencyKey), + ); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + function DepositFlow() { const params = useSearchParams(); const paramTier = params.get('tier') as TierId | null; @@ -63,13 +92,10 @@ function DepositFlow() { setIntent(active); setAmount(active.amount); setStep(4); - setTxStatus( - active.status === 'confirmed' - ? 'confirmed' - : active.status === 'failed' - ? 'failed' - : 'pending', - ); + // findActiveIntent already excludes terminal states (confirmed/failed/ + // expired — see isTerminal), so a rehydrated intent is always + // mid-flight; anything short of that bucket falls back to 'pending'. + setTxStatus('pending'); if (active.error) setTxError(active.error); // Intentionally mount-only: this is a one-time rehydration check, not a // live subscription to tier/address changes. @@ -112,23 +138,38 @@ function DepositFlow() { setIntent(awaiting); try { - // sdk.deposit({ tier: tier.label, amount, idempotencyKey: awaiting.key }) would go here - const txHash = await new Promise((resolve) => - setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000), - ); + const txHash = await placeholderSubmissionHash(awaiting.key); const submitted = markSubmitted(awaiting, txHash); setIntent(submitted); - const confirmed = markConfirmed(submitted); + + const pending = markPending(submitted); + setIntent(pending); + setTxStatus('pending'); + + // Real polling against the live Soroban RPC endpoint (issue #141) — + // not a timer. A 20s cap keeps manual testing bearable; production + // callers get waitForTransaction's full default (60s). + await waitForTransaction(txHash, { timeoutMs: 20_000 }); + + const confirmed = markConfirmed(pending); setIntent(confirmed); setTxStatus('confirmed'); } catch (error) { - const failed = markFailed( - awaiting, - error instanceof Error ? error.message : 'Transaction failed', - ); - setIntent(failed); - setTxStatus('failed'); - setTxError(failed.error ?? 'An error occurred. Please try again.'); + if (error instanceof TransactionTimedOutError) { + const expired = markExpired( + awaiting, + 'Could not confirm this transaction in time. It may still complete — check back before retrying.', + ); + setIntent(expired); + setTxStatus('expired'); + setTxError(expired.error ?? ''); + } else { + const message = error instanceof Error ? error.message : 'Transaction failed'; + const failed = markFailed(awaiting, message); + setIntent(failed); + setTxStatus('failed'); + setTxError(failed.error ?? 'An error occurred. Please try again.'); + } } finally { setSubmitting(false); } @@ -282,6 +323,18 @@ function DepositFlow() { )} + {txStatus === 'expired' && ( + <> +
?
+

Confirmation Timed Out

+

+ {txError || 'Could not confirm this transaction in time.'} +

+ + + )} )} diff --git a/app/src/hooks/useHarvestHistory.ts b/app/src/hooks/useHarvestHistory.ts index ad0d3c2..95d848e 100644 --- a/app/src/hooks/useHarvestHistory.ts +++ b/app/src/hooks/useHarvestHistory.ts @@ -32,6 +32,10 @@ export function useHarvestHistory(limit = 20): HarvestHistoryData { }); useEffect(() => { + // Audited for issue #141's stubbed-data pattern (see the identical + // note in useLastHarvest.ts): no real event indexer exists yet to + // wire this to, unlike usePosition's VaultRouter call. Left as an + // explicit, tracked stub. // TODO(GF-12): Replace with real event indexer queries const now = Date.now(); const events: HarvestEvent[] = Array.from({ length: limit }, (_, i) => { diff --git a/app/src/hooks/useLastHarvest.ts b/app/src/hooks/useLastHarvest.ts index f0382ab..2303ce2 100644 --- a/app/src/hooks/useLastHarvest.ts +++ b/app/src/hooks/useLastHarvest.ts @@ -41,6 +41,12 @@ export function useLastHarvest(): LastHarvestData { }); useEffect(() => { + // Audited for issue #141's stubbed-data pattern: unlike usePosition + // (which had a real VaultRouter contract to call and was fixed to + // reflect that), there is no deployed Harvester contract or indexer + // this hook could call yet — the RPC layer here has nothing real to + // wire to until GF-12 lands. Left as an explicit, tracked stub rather + // than aligned with usePosition's fix. // TODO(GF-12): Replace with Harvester contract Soroban RPC call const lastTimestamp = Date.now() - 3 * 24 * 60 * 60 * 1000; const elapsed = Math.floor((Date.now() - lastTimestamp) / 1000); diff --git a/app/src/hooks/usePosition.ts b/app/src/hooks/usePosition.ts index a77e17a..97297dc 100644 --- a/app/src/hooks/usePosition.ts +++ b/app/src/hooks/usePosition.ts @@ -1,5 +1,4 @@ import { useState, useEffect } from 'react'; -import { callRpc } from '../services/rpc'; export type Tier = 'Flex' | 'L3' | 'L6' | 'L12'; @@ -15,12 +14,15 @@ export interface Position { lockUntil: number | null; } -const VAULT_CONTRACT: Record = { - Flex: process.env.NEXT_PUBLIC_FLEX_VAULT, - L3: process.env.NEXT_PUBLIC_L3_VAULT, - L6: process.env.NEXT_PUBLIC_L6_VAULT, - L12: process.env.NEXT_PUBLIC_L12_VAULT, -}; +/** + * A single VaultRouter handles every tier — tier is a call argument to + * `position(address, tier, asset)`, not a separate deployed contract per + * tier (see sdks/typescript's YieldLadder, issue #139). This previously + * read `NEXT_PUBLIC_{TIER}_VAULT` per tier, which doesn't match how the + * contracts are actually deployed. + */ +const VAULT_ROUTER_CONTRACT_ID = process.env.NEXT_PUBLIC_VAULT_ROUTER_CONTRACT_ID; +const ASSET_CONTRACT_ID = process.env.NEXT_PUBLIC_ASSET_CONTRACT_ID; export function usePosition(address: string | null, tier: Tier) { const [position, setPosition] = useState(null); @@ -30,50 +32,35 @@ export function usePosition(address: string | null, tier: Tier) { useEffect(() => { if (!address) { setPosition(null); + setError(null); return; } - const contractId = VAULT_CONTRACT[tier]; - if (!contractId) { - setError(`Contract address for ${tier} vault not configured`); + if (!VAULT_ROUTER_CONTRACT_ID || !ASSET_CONTRACT_ID) { + setError('Vault router contract address not configured'); return; } - let cancelled = false; - setLoading(true); - setError(null); - - async function load() { - try { - // simulateTransaction would be used here with the TypeScript SDK. - // For now, query contract data via getLedgerEntries using the - // user's position storage key derived from their address. - const result = await callRpc<{ entries: unknown[] }>( - 'getLedgerEntries', - [{ keys: [`${contractId}:position:${address}`] }], - ); - if (!cancelled) { - // Parse XDR result once SDK is available; return zeros until then. - void result; - setPosition({ - tier, - shares: '0', - principal: '0', - accruedYield: '0', - lockUntil: null, - }); - setLoading(false); - } - } catch (e) { - if (!cancelled) { - setError(e instanceof Error ? e.message : String(e)); - setLoading(false); - } - } - } - - load(); - return () => { cancelled = true; }; + // TODO(#141 follow-up): call VaultRouter.position(address, tier, asset) + // via simulateTransaction — sdks/typescript's YieldLadder.position() + // already implements exactly this (issue #139), which is the + // "preferred" approach issue #141 calls for. Wiring it in here is + // blocked on the app depending on @yieldladder/sdk (or + // @stellar/stellar-sdk directly) for real XDR encode/decode, which + // needs a `pnpm install` to regenerate app/pnpm-lock.yaml correctly — + // this environment can't safely run that (see the identical blocker + // documented on placeholderSubmissionHash in app/deposit/page.tsx). + // + // Previously this returned a hardcoded { shares: '0', principal: '0', + // ... } dressed up as decoded on-chain data. A silently-wrong zero + // balance is worse than an explicit "unavailable" state — it can read + // as "you have nothing here" to a user who actually has a position. + // Surface that honestly instead of fabricating a result. + setLoading(false); + setPosition(null); + setError( + 'Position data is not yet available in the dashboard — SDK integration pending (see issue #141 follow-up)', + ); }, [address, tier]); return { position, loading, error }; diff --git a/app/src/lib/paymentIntent.test.ts b/app/src/lib/paymentIntent.test.ts index 5b1df51..414ddca 100644 --- a/app/src/lib/paymentIntent.test.ts +++ b/app/src/lib/paymentIntent.test.ts @@ -4,8 +4,10 @@ import { beginSubmission, markAwaitingSignature, markSubmitted, + markPending, markConfirmed, markFailed, + markExpired, findActiveIntent, loadIntent, isTerminal, @@ -170,4 +172,33 @@ describe('paymentIntent (issue #140)', () => { expect(reloaded).not.toBeNull(); expect(reloaded!.status).toBe('building'); }); + + it('markPending transitions submitted -> pending without releasing the lock (issue #141)', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + const submitted = markSubmitted(building, 'tx-hash-3', storage); + + const pending = markPending(submitted, storage); + expect(pending.status).toBe('pending'); + expect(isTerminal(pending.status)).toBe(false); + + // Lock is still held: a second tab racing the same operation is refused. + const other = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + expect(beginSubmission(other, storage)).toBeNull(); + }); + + it('markExpired is terminal and releases the lock so a retry is not blocked forever (issue #141)', () => { + const intent = createIntent('deposit', 'GADDR', 'Flex', 'USDC', '10', storage); + const building = beginSubmission(intent, storage)!; + const submitted = markSubmitted(building, 'tx-hash-4', storage); + const pending = markPending(submitted, storage); + + const expired = markExpired(pending, 'Timed out waiting for confirmation', storage); + expect(expired.status).toBe('expired'); + expect(isTerminal(expired.status)).toBe(true); + + const retried = beginSubmission(expired, storage); + expect(retried).not.toBeNull(); + expect(retried!.status).toBe('building'); + }); }); diff --git a/app/src/lib/paymentIntent.ts b/app/src/lib/paymentIntent.ts index e050725..bad1610 100644 --- a/app/src/lib/paymentIntent.ts +++ b/app/src/lib/paymentIntent.ts @@ -5,7 +5,9 @@ // Two records are persisted per logical operation: // - an "intent" (keyed by kind+address+tier+asset+amount+nonce) tracking // this specific attempt's state machine: idle -> building -> -// awaiting_signature -> submitted -> confirmed | failed. +// awaiting_signature -> submitted -> pending -> confirmed | failed | expired. +// (`submitted` means a tx hash exists; `pending` means the confirmation +// poller (issue #141) is actively watching that hash for inclusion.) // - an "operation lock" (keyed by kind+address+tier, NO nonce) pointing at // whichever intent currently holds the right to submit. This is what // stops two browser tabs racing the same deposit even though each tab @@ -18,13 +20,25 @@ export type IntentKind = 'deposit' | 'withdraw' | 'earlyExit'; +/** + * Mirrored 1:1 by `PaymentStatus` in sdks/typescript/src/types.ts (issue + * #141). The app and SDK are independently-versioned packages with + * separate pnpm-lock.yaml files (no shared workspace), so they can't share + * a single imported type — keep the literal sets identical by convention: + * if you add/rename a value here, update the SDK's copy too. `idle` and + * `building` have no SDK-side counterpart (pre-submission, app-local UI + * states); the SDK's `simulating` has no app-side counterpart (internal to + * `TransactionPipeline.doInvoke`, never observed by the app). + */ export type IntentStatus = | 'idle' | 'building' | 'awaiting_signature' | 'submitted' + | 'pending' | 'confirmed' - | 'failed'; + | 'failed' + | 'expired'; export interface PaymentIntent { key: string; @@ -111,7 +125,7 @@ function buildIntentKey( } export function isTerminal(status: IntentStatus): boolean { - return status === 'confirmed' || status === 'failed'; + return status === 'confirmed' || status === 'failed' || status === 'expired'; } export function createIntent( @@ -222,9 +236,10 @@ export function findActiveIntent( /** * The re-entry guard. Refuses (returns null) when: - * - this exact intent has already left idle/failed — a double-click on - * the same confirm button reuses the same already-generated intent, so - * its second call sees status !== idle/failed and is refused; or + * - this exact intent has already left idle/failed/expired — a + * double-click on the same confirm button reuses the same + * already-generated intent, so its second call sees a status outside + * that retry-eligible set and is refused; or * - a DIFFERENT intent for the same logical operation (kind/address/tier) * currently holds the lock and hasn't reached a terminal state — this * is what stops two browser tabs racing the same deposit even though @@ -244,7 +259,11 @@ export function beginSubmission( // (now-stale) React state object from before the first click's update, // so trusting `intent.status` here would defeat the guard entirely. const current = loadIntent(intent.key, storage) ?? intent; - if (current.status !== 'idle' && current.status !== 'failed') { + if ( + current.status !== 'idle' && + current.status !== 'failed' && + current.status !== 'expired' + ) { return null; } @@ -282,6 +301,18 @@ export function markSubmitted( return transition(intent, { status: 'submitted', txHash }, storage); } +/** + * The tx hash has been handed to the confirmation poller (issue #141) and + * is actively being watched for inclusion — distinct from `submitted`, + * which only means a hash exists. + */ +export function markPending( + intent: PaymentIntent, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + return transition(intent, { status: 'pending' }, storage); +} + export function markConfirmed( intent: PaymentIntent, storage: StorageLike = defaultStorage(), @@ -307,6 +338,24 @@ export function markFailed( return updated; } +/** + * The confirmation poller gave up before the hash ever resolved to a + * terminal on-chain outcome (still NOT_FOUND past the poll deadline) — + * distinct from `failed`: nothing confirmed the transaction was rejected, + * only that we stopped waiting. Also releases the operation lock, same + * reasoning as `markFailed`: the user must be able to retry rather than be + * stuck behind a lock for a transaction whose outcome is unknown. + */ +export function markExpired( + intent: PaymentIntent, + error: string, + storage: StorageLike = defaultStorage(), +): PaymentIntent { + const updated = transition(intent, { status: 'expired', error }, storage); + releaseLock(intent.kind, intent.address, intent.tier, storage); + return updated; +} + function transition( intent: PaymentIntent, patch: Partial, diff --git a/app/src/services/rpc.test.ts b/app/src/services/rpc.test.ts new file mode 100644 index 0000000..f24b296 --- /dev/null +++ b/app/src/services/rpc.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + waitForTransaction, + TransactionFailedError, + TransactionTimedOutError, +} from './rpc'; +import type { IntentStatus } from '../lib/paymentIntent'; + +function jsonRpcResponse(result: unknown) { + return { + ok: true, + status: 200, + json: async () => ({ jsonrpc: '2.0', id: 1, result }), + } as Response; +} + +describe('waitForTransaction (issue #141)', () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + it('resolves as soon as getTransaction reports SUCCESS', async () => { + fetchMock.mockResolvedValueOnce(jsonRpcResponse({ status: 'SUCCESS' })); + const statuses: IntentStatus[] = []; + + await waitForTransaction('hash-1', { onStatus: (s) => statuses.push(s) }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(statuses).toEqual(['pending', 'confirmed']); + }); + + it('keeps polling through NOT_FOUND (still propagating) until it resolves to SUCCESS', async () => { + fetchMock + .mockResolvedValueOnce(jsonRpcResponse({ status: 'NOT_FOUND' })) + .mockResolvedValueOnce(jsonRpcResponse({ status: 'NOT_FOUND' })) + .mockResolvedValueOnce(jsonRpcResponse({ status: 'SUCCESS' })); + + await waitForTransaction('hash-2', { pollIntervalMs: 1 }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('rejects with TransactionFailedError when included but failed on-chain — never reports confirmed', async () => { + fetchMock.mockResolvedValueOnce(jsonRpcResponse({ status: 'FAILED' })); + const statuses: IntentStatus[] = []; + + await expect( + waitForTransaction('hash-3', { onStatus: (s) => statuses.push(s) }), + ).rejects.toBeInstanceOf(TransactionFailedError); + expect(statuses).toEqual(['pending', 'failed']); + }); + + it('rejects with TransactionTimedOutError if it never leaves NOT_FOUND before the deadline', async () => { + fetchMock.mockResolvedValue(jsonRpcResponse({ status: 'NOT_FOUND' })); + const statuses: IntentStatus[] = []; + + await expect( + waitForTransaction('hash-4', { + pollIntervalMs: 1, + timeoutMs: 5, + onStatus: (s) => statuses.push(s), + }), + ).rejects.toBeInstanceOf(TransactionTimedOutError); + expect(statuses[0]).toBe('pending'); + expect(statuses[statuses.length - 1]).toBe('expired'); + }); +}); diff --git a/app/src/services/rpc.ts b/app/src/services/rpc.ts index cf3515e..3d39a35 100644 --- a/app/src/services/rpc.ts +++ b/app/src/services/rpc.ts @@ -1,3 +1,5 @@ +import type { IntentStatus } from '../lib/paymentIntent'; + const RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; @@ -36,3 +38,139 @@ export interface LedgerInfo { export function getLatestLedger(): Promise { return callRpc('getLatestLedger'); } + +// --- Transaction submission & confirmation (issue #141) --------------- +// +// There is currently no defined boundary anywhere between "the app +// believes a payment happened" and "the blockchain has actually confirmed +// it" — `sendTransaction` only reports whether the RPC node *accepted the +// transaction for relay*, never whether it was actually included and +// applied. `getTransaction`/`waitForTransaction` close that gap by polling +// for the real terminal outcome. + +export interface SendTransactionResult { + status: 'PENDING' | 'DUPLICATE' | 'TRY_AGAIN_LATER' | 'ERROR'; + hash: string; + errorResultXdr?: string; +} + +/** Submits an already-signed transaction envelope (base64 XDR) for relay. Does NOT mean it was included — see `waitForTransaction`. */ +export function sendTransaction( + signedTransactionXdr: string, +): Promise { + return callRpc('sendTransaction', [ + { transaction: signedTransactionXdr }, + ]); +} + +export interface GetTransactionResult { + status: 'NOT_FOUND' | 'SUCCESS' | 'FAILED'; + latestLedger?: number; + resultXdr?: string; +} + +export function getTransaction(hash: string): Promise { + return callRpc('getTransaction', [{ hash }]); +} + +/** + * A submitted transaction was included in a ledger but did not succeed — + * covers both an outright rejection and a contract call that reverted + * after inclusion. Soroban surfaces both as a non-SUCCESS `getTransaction` + * status (unlike chains where a reverted call can still be "successfully + * included"), so no separate XDR inspection is needed to catch this case. + */ +export class TransactionFailedError extends Error { + constructor(readonly hash: string) { + super(`Transaction ${hash} was included but failed on-chain`); + this.name = 'TransactionFailedError'; + } +} + +/** + * Polling gave up before `getTransaction` ever reported SUCCESS or FAILED + * — the hash stayed NOT_FOUND past the deadline. This can mean the + * transaction is still propagating somewhere slow, or that it was quietly + * dropped; either way the caller must stop waiting, and must not treat it + * as a confirmed failure (nothing confirmed it failed, only that we gave + * up watching). + */ +export class TransactionTimedOutError extends Error { + constructor(readonly hash: string) { + super(`Timed out waiting for transaction ${hash} to confirm`); + this.name = 'TransactionTimedOutError'; + } +} + +export interface WaitForTransactionOptions { + /** Fired with each observed lifecycle status as polling progresses. */ + onStatus?: (status: IntentStatus) => void; + /** Delay before the first poll and the base for exponential backoff between polls. Default 1500ms. */ + pollIntervalMs?: number; + /** Hard cap on total wait time before giving up with a TransactionTimedOutError. Default 60000ms. */ + timeoutMs?: number; +} + +const DEFAULT_POLL_INTERVAL_MS = 1_500; +const MAX_POLL_INTERVAL_MS = 5_000; +/** + * Confirmation-depth policy (issue #141): Stellar/Soroban's consensus + * (SCP) gives a ledger deterministic finality the moment it closes — there + * is no probabilistic reorg window the way Nakamoto-consensus chains have, + * so there is no separate "included but not yet safe" state to model. The + * instant `getTransaction` reports SUCCESS, the result is final. This + * timeout is generous past Soroban's typical propagation window purely to + * stop polling forever — a legitimate submission resolves in a few + * ledgers (a few seconds), well under this. + */ +const DEFAULT_POLL_TIMEOUT_MS = 60_000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Polls `getTransaction` for `hash` until it resolves to a terminal + * on-chain outcome, driving `onStatus` through the canonical status + * lifecycle (mirrors `TransactionPipeline.waitForTransaction` in the + * TypeScript SDK — see sdks/typescript/src/transactions.ts). Resolves on + * SUCCESS; rejects with `TransactionFailedError` on FAILED or + * `TransactionTimedOutError` if the deadline passes while still NOT_FOUND. + */ +export async function waitForTransaction( + hash: string, + opts: WaitForTransactionOptions = {}, +): Promise { + const baseIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const timeoutMs = opts.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + opts.onStatus?.('pending'); + + for (let attempt = 0; ; attempt++) { + const result = await getTransaction(hash); + + if (result.status === 'SUCCESS') { + opts.onStatus?.('confirmed'); + return; + } + + if (result.status === 'FAILED') { + opts.onStatus?.('failed'); + throw new TransactionFailedError(hash); + } + + // NOT_FOUND: keep polling until the deadline, then stop rather than + // loop forever — never assume a bare NOT_FOUND is itself a failure. + if (Date.now() >= deadline) { + opts.onStatus?.('expired'); + throw new TransactionTimedOutError(hash); + } + + const backoff = Math.min( + baseIntervalMs * 2 ** attempt, + MAX_POLL_INTERVAL_MS, + ); + await sleep(backoff); + } +} diff --git a/sdks/typescript/src/__tests__/waitForTransaction.test.ts b/sdks/typescript/src/__tests__/waitForTransaction.test.ts new file mode 100644 index 0000000..d49ba4f --- /dev/null +++ b/sdks/typescript/src/__tests__/waitForTransaction.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Keypair } from '@stellar/stellar-sdk'; +import { TransactionPipeline } from '../transactions'; +import { TransactionFailedError, TransactionTimedOutError } from '../errors'; +import type { PaymentStatus } from '../types'; + +const mockGetTransaction = vi.fn(); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual( + '@stellar/stellar-sdk', + ); + return { + ...actual, + SorobanRpc: { + ...actual.SorobanRpc, + Server: vi.fn().mockImplementation(() => ({ + getTransaction: (...args: unknown[]) => mockGetTransaction(...args), + })), + }, + }; +}); + +const userKeypair = Keypair.random(); + +function makePipeline(): TransactionPipeline { + return new TransactionPipeline({ + rpcUrl: 'https://example.invalid', + network: 'testnet', + publicKey: userKeypair.publicKey(), + signer: { signTransaction: vi.fn(async (xdrString: string) => xdrString) }, + }); +} + +describe('TransactionPipeline.waitForTransaction (issue #141)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves as soon as getTransaction reports SUCCESS', async () => { + mockGetTransaction.mockResolvedValueOnce({ status: 'SUCCESS' }); + const pipeline = makePipeline(); + const statuses: PaymentStatus[] = []; + + await pipeline.waitForTransaction('hash-1', { + onStatus: (s) => statuses.push(s), + }); + + expect(mockGetTransaction).toHaveBeenCalledTimes(1); + expect(mockGetTransaction).toHaveBeenCalledWith('hash-1'); + expect(statuses).toEqual(['pending', 'confirmed']); + }); + + it('keeps polling through NOT_FOUND (still propagating) until it resolves to SUCCESS', async () => { + mockGetTransaction + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'NOT_FOUND' }) + .mockResolvedValueOnce({ status: 'SUCCESS' }); + const pipeline = makePipeline(); + + await pipeline.waitForTransaction('hash-2', { pollIntervalMs: 1 }); + + expect(mockGetTransaction).toHaveBeenCalledTimes(3); + }); + + it('rejects with TransactionFailedError when the transaction was included but failed on-chain', async () => { + mockGetTransaction.mockResolvedValueOnce({ status: 'FAILED' }); + const pipeline = makePipeline(); + const statuses: PaymentStatus[] = []; + + await expect( + pipeline.waitForTransaction('hash-3', { + onStatus: (s) => statuses.push(s), + }), + ).rejects.toBeInstanceOf(TransactionFailedError); + expect(statuses).toEqual(['pending', 'failed']); + }); + + it('does not mistake a contract-level revert for confirmation — FAILED never resolves', async () => { + mockGetTransaction.mockResolvedValueOnce({ status: 'FAILED' }); + const pipeline = makePipeline(); + + const error = await pipeline + .waitForTransaction('hash-4') + .catch((e: unknown) => e); + + expect(error).toBeInstanceOf(TransactionFailedError); + expect((error as TransactionFailedError).hash).toBe('hash-4'); + }); + + it('rejects with TransactionTimedOutError if it never leaves NOT_FOUND before the deadline', async () => { + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + const pipeline = makePipeline(); + const statuses: PaymentStatus[] = []; + + await expect( + pipeline.waitForTransaction('hash-5', { + pollIntervalMs: 1, + timeoutMs: 5, + onStatus: (s) => statuses.push(s), + }), + ).rejects.toBeInstanceOf(TransactionTimedOutError); + expect(statuses[0]).toBe('pending'); + expect(statuses[statuses.length - 1]).toBe('expired'); + }); +}); diff --git a/sdks/typescript/src/errors.ts b/sdks/typescript/src/errors.ts index 456a40d..0a332ba 100644 --- a/sdks/typescript/src/errors.ts +++ b/sdks/typescript/src/errors.ts @@ -88,3 +88,31 @@ export class SubmissionFailedError extends YieldLadderError { this.name = 'SubmissionFailedError'; } } + +/** + * A submitted transaction was included in a ledger but did not succeed — + * covers both an outright tx-level rejection and a contract call that + * reverted after inclusion (Soroban surfaces both as a non-SUCCESS + * `getTransaction` status, unlike chains where a reverted call can still be + * "successfully included"). + */ +export class TransactionFailedError extends YieldLadderError { + constructor(readonly hash: string) { + super(`Transaction ${hash} was included but failed on-chain`); + this.name = 'TransactionFailedError'; + } +} + +/** + * Polling gave up before `getTransaction` ever reported SUCCESS or FAILED — + * the hash stayed NOT_FOUND past the poll deadline. This can mean the + * transaction is still propagating somewhere slow, or that it was quietly + * dropped; either way, the caller must stop waiting rather than poll + * forever, and should treat this as "unknown," not "confirmed failure." + */ +export class TransactionTimedOutError extends YieldLadderError { + constructor(readonly hash: string) { + super(`Timed out waiting for transaction ${hash} to confirm`); + this.name = 'TransactionTimedOutError'; + } +} diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 9b64842..32fbf19 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -1,4 +1,12 @@ -export type { Tier, Network, YieldLadderOptions, Position, Signer } from './types'; +export type { + Tier, + Network, + YieldLadderOptions, + Position, + Signer, + PaymentStatus, +} from './types'; +export type { WaitForTransactionOptions } from './transactions'; import type { Tier, YieldLadderOptions, Position, Network } from './types'; import { BelowMinDepositError } from './errors'; import { @@ -6,6 +14,7 @@ import { amountToScVal, tierToScVal, TransactionPipeline, + type WaitForTransactionOptions, } from './transactions'; const USDC_DECIMALS = 7; @@ -131,6 +140,21 @@ export class YieldLadder { }); } + /** + * Waits for a previously-submitted transaction (the hash returned by + * `deposit`/`withdraw`/`earlyExit`) to reach a terminal on-chain outcome. + * Resolves once confirmed; rejects with `TransactionFailedError` if it + * failed on-chain or `TransactionTimedOutError` if it never resolved + * before the deadline. See `WaitForTransactionOptions` for `onStatus`, + * polling interval, and timeout. + */ + async waitForConfirmation( + txHash: string, + opts?: WaitForTransactionOptions, + ): Promise { + return this.pipeline.waitForTransaction(txHash, opts); + } + private defaultIdempotencyKey( method: string, tier: Tier, diff --git a/sdks/typescript/src/transactions.ts b/sdks/typescript/src/transactions.ts index 9f0d63b..3cedab5 100644 --- a/sdks/typescript/src/transactions.ts +++ b/sdks/typescript/src/transactions.ts @@ -10,7 +10,7 @@ import { TransactionBuilder, xdr, } from '@stellar/stellar-sdk'; -import type { Network, Signer, Tier } from './types'; +import type { Network, PaymentStatus, Signer, Tier } from './types'; import { AssetNotAllowedError, BelowMinDepositError, @@ -21,9 +21,12 @@ import { SimulationFailedError, SubmissionFailedError, TransactionExpiredError, + TransactionFailedError, + TransactionTimedOutError, VaultContractError, YieldLadderError, } from './errors'; +import { sleep } from './utils'; const NETWORK_PASSPHRASES: Record = { mainnet: Networks.PUBLIC, @@ -32,6 +35,19 @@ const NETWORK_PASSPHRASES: Record = { const DEFAULT_TX_TIMEOUT_SECONDS = 30; +/** + * Confirmation-depth policy (issue #141): Stellar/Soroban's consensus + * (SCP) gives a ledger deterministic finality the moment it closes — there + * is no probabilistic reorg window the way Nakamoto-consensus chains have, + * so there is no separate "included but not yet safe" state to model here. + * The instant `getTransaction` reports SUCCESS, the result is final; no + * additional confirmation-depth wait is needed or meaningful. + */ +const DEFAULT_POLL_INTERVAL_MS = 1_500; +const MAX_POLL_INTERVAL_MS = 5_000; +/** Generous past Soroban's typical propagation window; a legitimate submission resolves in a few ledgers (~seconds), well under this. */ +const DEFAULT_POLL_TIMEOUT_MS = 60_000; + /** * Soroban surfaces contract-level rejections in the simulation error string * as `Error(Contract, #N)`, where N is the error enum's discriminant. @@ -62,6 +78,15 @@ export interface InvokeContractParams { idempotencyKey?: string; } +export interface WaitForTransactionOptions { + /** Fired with each observed lifecycle status as polling progresses. */ + onStatus?: (status: PaymentStatus) => void; + /** Delay before the first poll and the base for exponential backoff between polls. Default 1500ms. */ + pollIntervalMs?: number; + /** Hard cap on total wait time before giving up with a TransactionTimedOutError. Default 60000ms. */ + timeoutMs?: number; +} + /** Encodes a Tier the same way Soroban encodes any #[contracttype] enum variant. */ export function tierToScVal(tier: Tier): xdr.ScVal { return xdr.ScVal.scvVec([xdr.ScVal.scvSymbol(tier)]); @@ -141,6 +166,54 @@ export class TransactionPipeline { return promise; } + /** + * Polls `getTransaction` for `hash` until it resolves to a terminal + * on-chain outcome, driving `onStatus` through the canonical + * `PaymentStatus` lifecycle. Resolves on SUCCESS; rejects with + * `TransactionFailedError` on FAILED (this covers a contract-level + * revert too — see the confirmation-depth policy note above) or with + * `TransactionTimedOutError` if the deadline passes while the hash is + * still NOT_FOUND (still propagating, or genuinely dropped — the caller + * can't distinguish the two and must stop waiting either way). + */ + async waitForTransaction( + hash: string, + opts: WaitForTransactionOptions = {}, + ): Promise { + const baseIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const timeoutMs = opts.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + opts.onStatus?.('pending'); + + for (let attempt = 0; ; attempt++) { + const result = await this.server.getTransaction(hash); + + if (result.status === 'SUCCESS') { + opts.onStatus?.('confirmed'); + return; + } + + if (result.status === 'FAILED') { + opts.onStatus?.('failed'); + throw new TransactionFailedError(hash); + } + + // NOT_FOUND: keep polling until the deadline, then stop rather than + // loop forever — never assume a bare NOT_FOUND is itself a failure. + if (Date.now() >= deadline) { + opts.onStatus?.('expired'); + throw new TransactionTimedOutError(hash); + } + + const backoff = Math.min( + baseIntervalMs * 2 ** attempt, + MAX_POLL_INTERVAL_MS, + ); + await sleep(backoff); + } + } + private async doInvoke(params: InvokeContractParams): Promise { const rawTx = await this.buildTransaction(params); diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index e36c41d..de67f14 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -43,3 +43,21 @@ export interface Position { accruedYield: string; lockUntil: number | null; } + +/** + * Canonical transaction-lifecycle status (issue #141), mirrored 1:1 by + * `PaymentStatus` in app/src/lib/paymentIntent.ts's `IntentStatus`. The app + * and this SDK are independently-versioned packages with separate + * pnpm-lock.yaml files (no shared workspace), so the two can't share a + * single imported type — keep the literal sets identical by convention: if + * you add/rename a value here, update the app's copy too. + */ +export type PaymentStatus = + | 'building' + | 'simulating' + | 'awaiting_signature' + | 'submitted' + | 'pending' + | 'confirmed' + | 'failed' + | 'expired'; diff --git a/sdks/typescript/src/utils.ts b/sdks/typescript/src/utils.ts index 99d1b35..0bd3059 100644 --- a/sdks/typescript/src/utils.ts +++ b/sdks/typescript/src/utils.ts @@ -11,3 +11,7 @@ export function formatUSDC(amount: string): string { } return num.toFixed(2); } + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}