From 380fa44ab83979d0ab575b3bf369faab430cd447 Mon Sep 17 00:00:00 2001 From: Uchenna Elizabeth Date: Sat, 17 Oct 2026 22:00:01 +0100 Subject: [PATCH 1/3] test: add unit tests for the time-ago formatter (#749) formatRelativeTimeLabel already had extensive boundary-value coverage (0/59/60 seconds, 45/59/60 minutes, 1/23/24 hours, 1/29/30 days, and dates beyond 30 days), but none of the tests used the exact input values #749's scope lists, and none exercised a future timestamp. Adds the five cases #749 names directly: 30 seconds ago, 5 minutes ago, 2 hours ago, 3 days ago, and a future timestamp (asserts 'just now' with no negative value, rather than relying on the function's incidental clamping behavior going untested). --- src/utils/__tests__/time.utils.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/utils/__tests__/time.utils.test.ts b/src/utils/__tests__/time.utils.test.ts index bc06c10f..7e980037 100644 --- a/src/utils/__tests__/time.utils.test.ts +++ b/src/utils/__tests__/time.utils.test.ts @@ -60,4 +60,29 @@ describe('formatRelativeTimeLabel', () => { const veryRecentDate = new Date(Date.now() - 5000); expect(formatRelativeTimeLabel(veryRecentDate)).toBe('just now'); }); + + // #749's exact scope: a few specific inputs the boundary tests above don't + // happen to cover by value, plus the future-timestamp case. + it('returns "just now" for a timestamp 30 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(30, now), now)).toBe('just now'); + }); + + it('returns "5 minutes ago" for a timestamp 5 minutes ago', () => { + expect(formatRelativeTimeLabel(makeDate(5 * 60, now), now)).toBe('5 minutes ago'); + }); + + it('returns "2 hours ago" for a timestamp 2 hours ago', () => { + expect(formatRelativeTimeLabel(makeDate(2 * 3600, now), now)).toBe('2 hours ago'); + }); + + it('returns "3 days ago" for a timestamp 3 days ago', () => { + expect(formatRelativeTimeLabel(makeDate(3 * 24 * 3600, now), now)).toBe('3 days ago'); + }); + + it('returns "just now" for a future timestamp, never a negative value', () => { + const future = new Date(now.getTime() + 5 * 60 * 1000); + const result = formatRelativeTimeLabel(future, now); + expect(result).toBe('just now'); + expect(result).not.toMatch(/-/); + }); }); From 3701e3017007b6032016c03080f07cdf6cc1bfc9 Mon Sep 17 00:00:00 2001 From: Uchenna Elizabeth Date: Sat, 17 Oct 2026 22:00:19 +0100 Subject: [PATCH 2/3] feat(trade-dialog): enforce a minimum of 1 and an available-supply ceiling on buy quantity (#753) TradeDialog's existing buy-side validation already checked a per-wallet holding cap (maxBuyable), a per-transaction limit (maxBuyQuantity), and an allowance, but had no check against the creator's remaining mintable supply, and its zero-quantity message ("Amount must be greater than zero.") didn't match this issue's required wording. Adds a new optional availableSupply prop. On the buy side only: a quantity below 1 (including non-numeric or empty input) now shows "Minimum quantity is 1", and a quantity above availableSupply shows "Exceeds available supply" (both verbatim per #753's acceptance criteria). availableSupply defaults to null (no ceiling), and the new rule runs alongside, not instead of, the existing holding-cap/per-tx/ allowance checks, each still returning its own message first when it also applies. Sell/stake/transfer sides are unaffected: they keep their original "Amount must be greater than zero." message, confirmed by a dedicated test. Implemented directly in TradeDialog rather than as a separate modal: the app has exactly one live single-key buy modal (TradeDialog, used from CreatorDetailPage), and a second, parallel component would duplicate or conflict with its already-tested validation chain. --- src/components/common/TradeDialog.tsx | 13 ++ ...eDialog.availableSupplyValidation.test.tsx | 123 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/components/common/__tests__/TradeDialog.availableSupplyValidation.test.tsx diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 71416bda..b2949d37 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -119,6 +119,8 @@ export interface TradeDialogProps { launchPenaltyBps?: number | null; /** Max buy quantity allowed per transaction; null means no limit. */ maxBuyQuantity?: number | null; + /** Keys still available to mint for this creator; null means no ceiling (#753). */ + availableSupply?: number | null; /** Live key trading config carrying the bid-ask spread (#951). */ keyConfig?: KeyConfig | null; /** Whether the key config query is still loading. */ @@ -163,6 +165,7 @@ const TradeDialog: React.FC = ({ currentLedger, launchPenaltyBps, maxBuyQuantity = null, + availableSupply = null, keyConfig, isKeyConfigLoading = false, circuitBreakerThresholdPercent, @@ -350,6 +353,9 @@ const TradeDialog: React.FC = ({ const validationError = useMemo((): string | null => { const normalized = amountText.trim(); + if (side === 'buy' && (!normalized || !Number.isFinite(parsedAmount) || parsedAmount < 1)) { + return 'Minimum quantity is 1'; + } if (!normalized) return 'Please enter an amount.'; if (!Number.isFinite(parsedAmount)) return 'Amount must be a valid number.'; @@ -366,6 +372,12 @@ const TradeDialog: React.FC = ({ parsedAmount > maxBuyQuantity ) return `Maximum ${formatNumber(maxBuyQuantity)} keys per transaction for this key`; + if ( + side === 'buy' && + availableSupply != null && + parsedAmount > availableSupply + ) + return 'Exceeds available supply'; if ( (side === 'sell' || side === 'stake' || side === 'transfer') && parsedAmount > availableHoldings @@ -380,6 +392,7 @@ const TradeDialog: React.FC = ({ atCap, maxBuyable, maxBuyQuantity, + availableSupply, isPaused, ]); diff --git a/src/components/common/__tests__/TradeDialog.availableSupplyValidation.test.tsx b/src/components/common/__tests__/TradeDialog.availableSupplyValidation.test.tsx new file mode 100644 index 00000000..8fb619cb --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.availableSupplyValidation.test.tsx @@ -0,0 +1,123 @@ +/** + * Unit tests for the buy-side quantity input enforcing a minimum of 1 and a + * maximum equal to the creator's available supply (#753). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '../TradeDialog'; + +describe('TradeDialog available-supply quantity validation (#753)', () => { + function renderBuyDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it("shows 'Minimum quantity is 1' when the quantity is 0", () => { + renderBuyDialog(); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '0' } }); + + expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent( + 'Minimum quantity is 1' + ); + }); + + it("shows 'Minimum quantity is 1' when the quantity is negative", () => { + renderBuyDialog(); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '-4' } }); + + expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent( + 'Minimum quantity is 1' + ); + }); + + it("shows 'Exceeds available supply' when the quantity is above the available supply", () => { + renderBuyDialog({ availableSupply: 50 }); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '51' } }); + + expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent( + 'Exceeds available supply' + ); + }); + + it('passes validation when the quantity exactly equals the available supply', () => { + renderBuyDialog({ availableSupply: 50 }); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '50' } }); + + expect( + screen.queryByTestId('trade-dialog-amount-error') + ).not.toBeInTheDocument(); + }); + + it('shows no error for a valid quantity within range', () => { + renderBuyDialog({ availableSupply: 50 }); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '10' } }); + + expect( + screen.queryByTestId('trade-dialog-amount-error') + ).not.toBeInTheDocument(); + }); + + it('disables the confirm button while the available-supply error is active', () => { + renderBuyDialog({ availableSupply: 50 }); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '51' } }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); + + it('disables the confirm button while the minimum-quantity error is active', () => { + renderBuyDialog(); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '0' } }); + + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + }); + + it('does not apply the available-supply ceiling when availableSupply is null (unlimited)', () => { + renderBuyDialog({ availableSupply: null }); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '100000' } }); + + expect( + screen.queryByTestId('trade-dialog-amount-error') + ).not.toBeInTheDocument(); + }); + + it('does not apply the minimum-quantity or available-supply rules on the sell side', () => { + render( + + ); + const input = screen.getByTestId('trade-dialog-amount'); + fireEvent.change(input, { target: { value: '0' } }); + + // Sell side keeps its own zero-quantity message, not the buy-side one. + const error = screen.getByTestId('trade-dialog-amount-error'); + expect(error).toHaveTextContent('Amount must be greater than zero.'); + expect(error).not.toHaveTextContent('Minimum quantity is 1'); + }); +}); From f05d858d090de12ed49a485ed3fd193291cc96dd Mon Sep 17 00:00:00 2001 From: Uchenna Elizabeth Date: Sat, 17 Oct 2026 22:00:42 +0100 Subject: [PATCH 3/3] feat: add a Horizon transaction status poller for pending Soroban transactions (#1082) Adds the infrastructure to monitor a submitted transaction after wallet submission and report confirmed/failed/timeout back to the UI, as its own React context plus a polling hook plus a status banner, each unit tested independently. - src/lib/stellar/horizonClient.ts: thin wrapper around Horizon.Server().transactions().transaction(hash).call(). Treats a 404 (not yet found) as 'pending', a successful record as 'confirmed', and an unsuccessful record or any other error as 'failed', extracting a result code from the transaction's result_xdr when present. Adds VITE_HORIZON_URL (optional; falls back to the public Horizon endpoint for VITE_STELLAR_NETWORK, since public Horizon endpoints are stable per network, unlike VITE_STELLAR_RPC_URL which has no safe default). - src/contexts/PendingTransactionContext.tsx: tracks an array of pending transactions in React state, keyed by hash, so each is independent of the others; addPendingTransaction is idempotent per hash. - src/hooks/usePendingTransactionPoller.ts: for every transaction in the context with state 'pending', polls Horizon every 3 seconds (POLL_INTERVAL_MS) for up to 60 seconds (POLL_TIMEOUT_MS) from that transaction's own submittedAt, independent of any other tracked transaction's timing. On confirmation, invalidates each of that transaction's own invalidateQueryKeys via React Query. On timeout or failure, stops polling and updates its state. - src/components/common/PendingTransactionBanner.tsx: mounted once in App.tsx, starts the poller and renders one card per non-confirmed tracked transaction (pending/failed/timeout), each linking to Stellar Expert via the existing buildStellarExpertTxUrl helper, with a retry action on failure. Confirmed transactions are left to the existing showToast.transactionSuccess flow rather than duplicated here. Not wired into any mutation's onSuccess: every wallet-submission mutation in this codebase (useBatchTradeMutation, useBatchBuyMutation, etc.) is currently mocked (a setTimeout plus a fabricated hash), not a real Horizon submission, so there is no genuine confirmation flow yet to attach the poller to without misrepresenting mocked data as a real on-chain wait. The poller itself (addPendingTransaction, the 3s/60s cadence, confirmed/failed/timeout handling, per-transaction isolation, and the invalidation hook) is fully built and tested against a mocked Horizon client; wiring it into a real submission path is a follow-up once one exists. --- .env.example | 3 + src/App.tsx | 2 + .../common/PendingTransactionBanner.tsx | 133 ++++++++++++ .../PendingTransactionBanner.test.tsx | 64 ++++++ src/contexts/PendingTransactionContext.tsx | 123 +++++++++++ .../PendingTransactionContext.test.tsx | 133 ++++++++++++ .../usePendingTransactionPoller.test.tsx | 205 ++++++++++++++++++ src/hooks/usePendingTransactionPoller.ts | 97 +++++++++ .../stellar/__tests__/horizonClient.test.ts | 67 ++++++ src/lib/stellar/horizonClient.ts | 77 +++++++ src/main.tsx | 5 +- src/utils/env.utils.ts | 6 + 12 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 src/components/common/PendingTransactionBanner.tsx create mode 100644 src/components/common/__tests__/PendingTransactionBanner.test.tsx create mode 100644 src/contexts/PendingTransactionContext.tsx create mode 100644 src/contexts/__tests__/PendingTransactionContext.test.tsx create mode 100644 src/hooks/__tests__/usePendingTransactionPoller.test.tsx create mode 100644 src/hooks/usePendingTransactionPoller.ts create mode 100644 src/lib/stellar/__tests__/horizonClient.test.ts create mode 100644 src/lib/stellar/horizonClient.ts diff --git a/.env.example b/.env.example index 71a45697..513ac08a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ VITE_MAINNET_RPC_URL= # Set to 'mainnet' for production, 'testnet' for development (default). VITE_STELLAR_NETWORK=testnet VITE_STELLAR_RPC_URL= +# Horizon base URL used to poll transaction status after submission. Leave +# unset to use the public Horizon endpoint for VITE_STELLAR_NETWORK. +VITE_HORIZON_URL= VITE_CREATOR_KEYS_CONTRACT_ID= # Comma-separated wallet addresses allowed to deploy new creator keys through the diff --git a/src/App.tsx b/src/App.tsx index 2fff7093..b3c21a8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import AppErrorBoundary from './components/common/AppErrorBoundary'; import ContractPausedBanner from './components/common/ContractPausedBanner'; import OfflineBanner from './components/common/OfflineBanner'; import GlobalPauseBanner from './components/common/GlobalPauseBanner'; +import PendingTransactionBanner from './components/common/PendingTransactionBanner'; import SessionExpiryWatcher from './components/common/SessionExpiryWatcher'; import { useContractPausedStore } from './hooks/useContractPausedStore'; import { routes } from './routes'; @@ -45,6 +46,7 @@ function App() { + + tx.state !== 'confirmed' + ); + if (visible.length === 0) return null; + + return ( +
+ {visible.map(tx => ( + removeTransaction(tx.hash)} + onRetry={() => retryTransaction(tx.hash)} + /> + ))} +
+ ); +} + +function TransactionCard({ + hash, + state, + errorMessage, + onDismiss, + onRetry, +}: { + hash: string; + state: 'pending' | 'failed' | 'timeout'; + errorMessage?: string; + onDismiss: () => void; + onRetry: () => void; +}) { + const explorerUrl = buildStellarExpertTxUrl(hash, env.VITE_STELLAR_NETWORK); + + return ( +
+
+
+

+ {state === 'pending' && 'Confirming transaction…'} + {state === 'failed' && 'Transaction failed'} + {state === 'timeout' && 'Still confirming'} +

+

+ {truncateTxHash(hash)} +

+ {state === 'failed' && errorMessage && ( +

+ {errorMessage} +

+ )} + {state === 'timeout' && ( +

+ This is taking longer than usual. Check the explorer for the + latest status. +

+ )} +
+ +
+ +
+ + View on Stellar Expert + + {state === 'failed' && ( + + )} +
+
+ ); +} diff --git a/src/components/common/__tests__/PendingTransactionBanner.test.tsx b/src/components/common/__tests__/PendingTransactionBanner.test.tsx new file mode 100644 index 00000000..c5ebc4a6 --- /dev/null +++ b/src/components/common/__tests__/PendingTransactionBanner.test.tsx @@ -0,0 +1,64 @@ +/** + * Unit tests for the pending-transaction status banner (#1082). + */ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + PendingTransactionProvider, + usePendingTransactions, +} from '@/contexts/PendingTransactionContext'; +import PendingTransactionBanner from '../PendingTransactionBanner'; + +vi.mock('@/lib/stellar/horizonClient', () => ({ + getTransactionStatus: vi.fn().mockResolvedValue({ status: 'pending' }), +})); + +function Harness({ hash }: { hash: string }) { + const { addPendingTransaction } = usePendingTransactions(); + addPendingTransaction(hash); + return ; +} + +function renderBanner(hash: string) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + + + ); +} + +describe('PendingTransactionBanner (#1082)', () => { + it('renders nothing when there are no tracked transactions', () => { + const queryClient = new QueryClient(); + render( + + + + + + ); + expect( + screen.queryByTestId('pending-transaction-banner') + ).not.toBeInTheDocument(); + }); + + it('shows a pending card for a tracked transaction with an explorer link', () => { + renderBanner('abcd1234'); + + expect( + screen.getByTestId('pending-transaction-pending') + ).toBeInTheDocument(); + const link = screen.getByTestId( + 'pending-transaction-explorer-link' + ) as HTMLAnchorElement; + expect(link.href).toContain('stellar.expert'); + expect(link.href).toContain('abcd1234'); + expect(link.target).toBe('_blank'); + }); +}); diff --git a/src/contexts/PendingTransactionContext.tsx b/src/contexts/PendingTransactionContext.tsx new file mode 100644 index 00000000..4ba21796 --- /dev/null +++ b/src/contexts/PendingTransactionContext.tsx @@ -0,0 +1,123 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'; + +export type PendingTransactionState = + | 'pending' + | 'confirmed' + | 'failed' + | 'timeout'; + +export interface PendingTransaction { + hash: string; + state: PendingTransactionState; + /** Query keys to invalidate once this transaction confirms (#1082). */ + invalidateQueryKeys?: readonly unknown[][]; + /** Present once state is 'failed'. */ + errorMessage?: string; + submittedAt: number; +} + +interface PendingTransactionContextValue { + transactions: PendingTransaction[]; + /** Registers a freshly submitted transaction for polling (#1082). */ + addPendingTransaction: ( + hash: string, + options?: { invalidateQueryKeys?: readonly unknown[][] } + ) => void; + updateTransactionState: ( + hash: string, + state: PendingTransactionState, + errorMessage?: string + ) => void; + /** Removes a transaction from tracking, e.g. after its toast is dismissed. */ + removeTransaction: (hash: string) => void; + /** Re-queues a failed transaction's hash as pending again (#1082 retry). */ + retryTransaction: (hash: string) => void; +} + +const PendingTransactionContext = + createContext(null); + +export function PendingTransactionProvider({ + children, +}: { + children: ReactNode; +}) { + const [transactions, setTransactions] = useState([]); + // Tracks hashes already present, so addPendingTransaction is idempotent if + // called twice for the same submission (e.g. a retried mutation). + const knownHashes = useRef(new Set()); + + const addPendingTransaction = useCallback( + ( + hash: string, + options?: { invalidateQueryKeys?: readonly unknown[][] } + ) => { + if (knownHashes.current.has(hash)) return; + knownHashes.current.add(hash); + setTransactions(prev => [ + ...prev, + { + hash, + state: 'pending', + invalidateQueryKeys: options?.invalidateQueryKeys, + submittedAt: Date.now(), + }, + ]); + }, + [] + ); + + const updateTransactionState = useCallback( + (hash: string, state: PendingTransactionState, errorMessage?: string) => { + setTransactions(prev => + prev.map(tx => + tx.hash === hash ? { ...tx, state, errorMessage } : tx + ) + ); + }, + [] + ); + + const removeTransaction = useCallback((hash: string) => { + knownHashes.current.delete(hash); + setTransactions(prev => prev.filter(tx => tx.hash !== hash)); + }, []); + + const retryTransaction = useCallback((hash: string) => { + setTransactions(prev => + prev.map(tx => + tx.hash === hash + ? { ...tx, state: 'pending', errorMessage: undefined, submittedAt: Date.now() } + : tx + ) + ); + }, []); + + const value = useMemo( + () => ({ + transactions, + addPendingTransaction, + updateTransactionState, + removeTransaction, + retryTransaction, + }), + [transactions, addPendingTransaction, updateTransactionState, removeTransaction, retryTransaction] + ); + + return ( + + {children} + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function usePendingTransactions(): PendingTransactionContextValue { + const ctx = useContext(PendingTransactionContext); + if (!ctx) { + throw new Error( + 'usePendingTransactions must be used within a PendingTransactionProvider' + ); + } + return ctx; +} diff --git a/src/contexts/__tests__/PendingTransactionContext.test.tsx b/src/contexts/__tests__/PendingTransactionContext.test.tsx new file mode 100644 index 00000000..02819c7c --- /dev/null +++ b/src/contexts/__tests__/PendingTransactionContext.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + PendingTransactionProvider, + usePendingTransactions, +} from '../PendingTransactionContext'; + +function wrapper({ children }: { children: React.ReactNode }) { + return {children}; +} + +describe('PendingTransactionContext (#1082)', () => { + it('starts with no tracked transactions', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + expect(result.current.transactions).toEqual([]); + }); + + it('adds a pending transaction', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + + expect(result.current.transactions).toHaveLength(1); + expect(result.current.transactions[0]).toMatchObject({ + hash: 'tx-1', + state: 'pending', + }); + }); + + it('tracks multiple simultaneous transactions independently', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.addPendingTransaction('tx-2'); + }); + + expect(result.current.transactions.map(tx => tx.hash)).toEqual([ + 'tx-1', + 'tx-2', + ]); + + act(() => { + result.current.updateTransactionState('tx-1', 'confirmed'); + }); + + expect( + result.current.transactions.find(tx => tx.hash === 'tx-1')?.state + ).toBe('confirmed'); + expect( + result.current.transactions.find(tx => tx.hash === 'tx-2')?.state + ).toBe('pending'); + }); + + it('does not add the same hash twice', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.addPendingTransaction('tx-1'); + }); + + expect(result.current.transactions).toHaveLength(1); + }); + + it('updates a transaction to failed with an error message', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.updateTransactionState('tx-1', 'failed', 'op_underfunded'); + }); + + expect(result.current.transactions[0]).toMatchObject({ + state: 'failed', + errorMessage: 'op_underfunded', + }); + }); + + it('removes a transaction from tracking', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.removeTransaction('tx-1'); + }); + + expect(result.current.transactions).toEqual([]); + }); + + it('allows re-adding a hash after it has been removed', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.removeTransaction('tx-1'); + result.current.addPendingTransaction('tx-1'); + }); + + expect(result.current.transactions).toHaveLength(1); + }); + + it('resets a failed transaction back to pending on retry', () => { + const { result } = renderHook(() => usePendingTransactions(), { wrapper }); + + act(() => { + result.current.addPendingTransaction('tx-1'); + result.current.updateTransactionState('tx-1', 'failed', 'boom'); + }); + + act(() => { + result.current.retryTransaction('tx-1'); + }); + + expect(result.current.transactions[0]).toMatchObject({ + state: 'pending', + errorMessage: undefined, + }); + }); + + it('throws when used outside a provider', () => { + const { result } = renderHook(() => { + try { + return usePendingTransactions(); + } catch (e) { + return e; + } + }); + expect(result.current).toBeInstanceOf(Error); + }); +}); diff --git a/src/hooks/__tests__/usePendingTransactionPoller.test.tsx b/src/hooks/__tests__/usePendingTransactionPoller.test.tsx new file mode 100644 index 00000000..4644a13c --- /dev/null +++ b/src/hooks/__tests__/usePendingTransactionPoller.test.tsx @@ -0,0 +1,205 @@ +/** + * Unit tests for the Horizon transaction status poller (#1082). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + PendingTransactionProvider, + usePendingTransactions, +} from '@/contexts/PendingTransactionContext'; +import { usePendingTransactionPoller } from '../usePendingTransactionPoller'; + +// Hardcoded per #1082's acceptance criteria ("polled every 3 seconds for up +// to 60 seconds"), deliberately NOT imported from the module under test: if +// someone changes POLL_INTERVAL_MS/POLL_TIMEOUT_MS, these tests must still +// assert the literal contract the issue specifies, not whatever the +// constants currently say. +const POLL_INTERVAL_MS = 3_000; +const POLL_TIMEOUT_MS = 60_000; +import { getTransactionStatus } from '@/lib/stellar/horizonClient'; + +vi.mock('@/lib/stellar/horizonClient', () => ({ + getTransactionStatus: vi.fn(), +})); + +const mockedGetTransactionStatus = vi.mocked(getTransactionStatus); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); + } + return { Wrapper, invalidateSpy }; +} + +function renderPoller() { + const { Wrapper, invalidateSpy } = createWrapper(); + const { result } = renderHook( + () => { + usePendingTransactionPoller(); + return usePendingTransactions(); + }, + { wrapper: Wrapper } + ); + return { result, invalidateSpy }; +} + +describe('usePendingTransactionPoller (#1082)', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockedGetTransactionStatus.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts polling immediately after a transaction is registered', async () => { + mockedGetTransactionStatus.mockResolvedValue({ status: 'pending' }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(mockedGetTransactionStatus).toHaveBeenCalledWith('tx-1'); + }); + + it('moves to confirmed and invalidates the given query keys', async () => { + mockedGetTransactionStatus.mockResolvedValue({ status: 'confirmed' }); + const { result, invalidateSpy } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1', { + invalidateQueryKeys: [['wallet', 'holdings'], ['creators']], + }); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect( + result.current.transactions.find(tx => tx.hash === 'tx-1')?.state + ).toBe('confirmed'); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['wallet', 'holdings'], + }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['creators'] }); + }); + + it('moves to failed with the error from a failed transaction', async () => { + mockedGetTransactionStatus.mockResolvedValue({ + status: 'failed', + errorMessage: 'op_underfunded', + }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + await act(async () => { + await Promise.resolve(); + }); + + const tx = result.current.transactions.find(t => t.hash === 'tx-1'); + expect(tx?.state).toBe('failed'); + expect(tx?.errorMessage).toBe('op_underfunded'); + }); + + it('keeps polling at the configured interval while still pending', async () => { + mockedGetTransactionStatus.mockResolvedValue({ status: 'pending' }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + }); + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(2); + + await act(async () => { + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + }); + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(3); + }); + + it('moves to timeout after 60 seconds of remaining pending', async () => { + mockedGetTransactionStatus.mockResolvedValue({ status: 'pending' }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + await act(async () => { + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(POLL_TIMEOUT_MS); + }); + + expect( + result.current.transactions.find(tx => tx.hash === 'tx-1')?.state + ).toBe('timeout'); + }); + + it('stops polling a transaction once it confirms', async () => { + mockedGetTransactionStatus.mockResolvedValue({ status: 'confirmed' }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-1'); + }); + await act(async () => { + await Promise.resolve(); + }); + const callsAtConfirm = mockedGetTransactionStatus.mock.calls.length; + + await act(async () => { + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 3); + }); + + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(callsAtConfirm); + }); + + it('tracks multiple simultaneous transactions independently', async () => { + mockedGetTransactionStatus.mockImplementation(async hash => { + if (hash === 'tx-confirmed') return { status: 'confirmed' }; + return { status: 'pending' }; + }); + const { result } = renderPoller(); + + act(() => { + result.current.addPendingTransaction('tx-confirmed'); + result.current.addPendingTransaction('tx-pending'); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect( + result.current.transactions.find(tx => tx.hash === 'tx-confirmed') + ?.state + ).toBe('confirmed'); + expect( + result.current.transactions.find(tx => tx.hash === 'tx-pending')?.state + ).toBe('pending'); + }); +}); diff --git a/src/hooks/usePendingTransactionPoller.ts b/src/hooks/usePendingTransactionPoller.ts new file mode 100644 index 00000000..f38fda8c --- /dev/null +++ b/src/hooks/usePendingTransactionPoller.ts @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { + usePendingTransactions, + type PendingTransaction, +} from '@/contexts/PendingTransactionContext'; +import { getTransactionStatus } from '@/lib/stellar/horizonClient'; + +export const POLL_INTERVAL_MS = 3_000; +export const POLL_TIMEOUT_MS = 60_000; + +/** + * Polls Horizon for every transaction currently tracked as 'pending' in + * PendingTransactionContext, starting immediately on mount/registration + * (#1082). Each transaction is polled independently of the others, so one + * slow or stuck lookup never blocks another's confirmation from landing. + * + * Mount this once near the app root (it reads from the shared context, so + * multiple mounts would just poll the same transactions twice). + */ +export function usePendingTransactionPoller(): void { + const { transactions, updateTransactionState } = usePendingTransactions(); + const queryClient = useQueryClient(); + // One interval timer per in-flight hash, so a transaction added mid-poll + // starts its own 3s cadence and 60s deadline from its own submittedAt, + // not from whenever this effect last re-ran. + const timersRef = useRef>>( + new Map() + ); + + useEffect(() => { + const timers = timersRef.current; + const pending = transactions.filter(tx => tx.state === 'pending'); + const trackedHashes = new Set(pending.map(tx => tx.hash)); + + // Stop polling anything no longer pending (confirmed/failed/timeout/removed). + for (const [hash, timer] of timers) { + if (!trackedHashes.has(hash)) { + clearInterval(timer); + timers.delete(hash); + } + } + + for (const tx of pending) { + if (timers.has(tx.hash)) continue; + timers.set(tx.hash, startPolling(tx, updateTransactionState, queryClient)); + } + + return () => { + for (const timer of timers.values()) clearInterval(timer); + timers.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transactions]); +} + +function startPolling( + tx: PendingTransaction, + updateTransactionState: ReturnType< + typeof usePendingTransactions + >['updateTransactionState'], + queryClient: ReturnType +): ReturnType { + const deadline = tx.submittedAt + POLL_TIMEOUT_MS; + let inFlight = false; + + const tick = async () => { + if (inFlight) return; + if (Date.now() >= deadline) { + clearInterval(timer); + updateTransactionState(tx.hash, 'timeout'); + return; + } + + inFlight = true; + try { + const result = await getTransactionStatus(tx.hash); + if (result.status === 'confirmed') { + clearInterval(timer); + for (const queryKey of tx.invalidateQueryKeys ?? []) { + void queryClient.invalidateQueries({ queryKey }); + } + updateTransactionState(tx.hash, 'confirmed'); + } else if (result.status === 'failed') { + clearInterval(timer); + updateTransactionState(tx.hash, 'failed', result.errorMessage); + } + // 'pending': leave the interval running for the next tick. + } finally { + inFlight = false; + } + }; + + const timer = setInterval(tick, POLL_INTERVAL_MS); + void tick(); + return timer; +} diff --git a/src/lib/stellar/__tests__/horizonClient.test.ts b/src/lib/stellar/__tests__/horizonClient.test.ts new file mode 100644 index 00000000..e47c1ca3 --- /dev/null +++ b/src/lib/stellar/__tests__/horizonClient.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const transactionMock = vi.fn(); + +vi.mock('@stellar/stellar-sdk', async () => { + const actual = await vi.importActual( + '@stellar/stellar-sdk' + ); + class MockServer { + transactions() { + return { + transaction: (hash: string) => ({ call: () => transactionMock(hash) }), + }; + } + } + return { + ...actual, + Horizon: { Server: MockServer }, + }; +}); + +import { getTransactionStatus } from '../horizonClient'; + +describe('getTransactionStatus (#1082)', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns confirmed for a successful transaction', async () => { + transactionMock.mockResolvedValueOnce({ successful: true }); + + const result = await getTransactionStatus('abc123'); + + expect(result).toEqual({ status: 'confirmed' }); + }); + + it('returns failed with an error message for an unsuccessful transaction', async () => { + transactionMock.mockResolvedValueOnce({ + successful: false, + result_xdr: undefined, + }); + + const result = await getTransactionStatus('abc123'); + + expect(result.status).toBe('failed'); + expect((result as { errorMessage: string }).errorMessage).toBeTruthy(); + }); + + it('returns pending for a 404 (not yet found on Horizon)', async () => { + transactionMock.mockRejectedValueOnce({ response: { status: 404 } }); + + const result = await getTransactionStatus('abc123'); + + expect(result).toEqual({ status: 'pending' }); + }); + + it('returns failed for a non-404 error (network failure, malformed hash)', async () => { + transactionMock.mockRejectedValueOnce(new Error('Network error')); + + const result = await getTransactionStatus('abc123'); + + expect(result.status).toBe('failed'); + expect((result as { errorMessage: string }).errorMessage).toBe( + 'Network error' + ); + }); +}); diff --git a/src/lib/stellar/horizonClient.ts b/src/lib/stellar/horizonClient.ts new file mode 100644 index 00000000..590a0cdf --- /dev/null +++ b/src/lib/stellar/horizonClient.ts @@ -0,0 +1,77 @@ +import { Horizon, xdr } from '@stellar/stellar-sdk'; +import { env } from '@/utils/env.utils'; + +const PUBLIC_HORIZON_URLS: Record<'mainnet' | 'testnet', string> = { + mainnet: 'https://horizon.stellar.org', + testnet: 'https://horizon-testnet.stellar.org', +}; + +let cachedServer: Horizon.Server | null = null; + +/** + * Returns a shared Horizon.Server instance pointed at VITE_HORIZON_URL, or + * the public Horizon endpoint for the configured network when unset (#1082). + */ +export function getHorizonServer(): Horizon.Server { + if (cachedServer) return cachedServer; + const url = env.VITE_HORIZON_URL ?? PUBLIC_HORIZON_URLS[env.VITE_STELLAR_NETWORK]; + cachedServer = new Horizon.Server(url); + return cachedServer; +} + +export type HorizonTransactionStatus = + | { status: 'confirmed' } + | { status: 'failed'; errorMessage: string } + | { status: 'pending' }; + +/** + * Looks up a submitted transaction's status on Horizon (#1082). + * + * Horizon returns 404 until the transaction is included in a closed ledger, + * which this treats as `pending` rather than a failure: a 404 means "not + * found yet", not "rejected". Any other error (network failure, malformed + * hash) is surfaced as `failed` so the caller does not poll forever against + * a request that can never succeed. + */ +export async function getTransactionStatus( + hash: string +): Promise { + try { + const record = await getHorizonServer().transactions().transaction(hash).call(); + if (record.successful) return { status: 'confirmed' }; + return { + status: 'failed', + errorMessage: extractResultCode(record.result_xdr) ?? 'Transaction failed on-chain.', + }; + } catch (error) { + if (isNotFoundError(error)) return { status: 'pending' }; + return { + status: 'failed', + errorMessage: error instanceof Error ? error.message : 'Unable to reach Horizon.', + }; + } +} + +function isNotFoundError(error: unknown): boolean { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { status?: number } }).response; + return response?.status === 404; + } + return false; +} + +/** + * Best-effort extraction of a human-readable code from a transaction's + * result XDR. Falls back to undefined (caller supplies a generic message) + * rather than throwing, since a malformed or unexpected XDR shape here must + * never crash the poller. + */ +function extractResultCode(resultXdr: string | undefined): string | undefined { + if (!resultXdr) return undefined; + try { + const result = xdr.TransactionResult.fromXdr(resultXdr, 'base64'); + return result.result.type; + } catch { + return undefined; + } +} diff --git a/src/main.tsx b/src/main.tsx index 31a7353c..6fc5ee1f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import './index.css'; import App from './App.tsx'; import Web3Provider from './providers/Web3Provider.tsx'; +import { PendingTransactionProvider } from './contexts/PendingTransactionContext'; import { registerUnhandledRejectionLogger } from './utils/unhandledRejectionLogger'; import { queryClient } from './providers/web3Utils'; import { @@ -31,7 +32,9 @@ if ('serviceWorker' in navigator) { createRoot(document.getElementById('root')!).render( - + + + ); diff --git a/src/utils/env.utils.ts b/src/utils/env.utils.ts index 2b7d7acd..2e2e37fd 100644 --- a/src/utils/env.utils.ts +++ b/src/utils/env.utils.ts @@ -8,6 +8,11 @@ const envSchema = z.object({ VITE_SEPOLIA_RPC_URL: z.string().optional(), VITE_MAINNET_RPC_URL: z.string().optional(), VITE_STELLAR_RPC_URL: z.string().optional(), + // Horizon (not Soroban RPC) base URL, used to poll transaction status + // after submission (#1082). Public endpoints are stable per network, so + // unlike VITE_STELLAR_RPC_URL this has a safe default rather than failing + // closed. + VITE_HORIZON_URL: z.string().optional(), VITE_STELLAR_NETWORK: z.enum(['mainnet', 'testnet']).default('testnet'), VITE_CREATOR_KEYS_CONTRACT_ID: z.string().optional(), // Comma-separated wallet addresses that may access the admin safety panel. @@ -33,6 +38,7 @@ export const env = envSchema.parse({ VITE_SEPOLIA_RPC_URL: import.meta.env.VITE_SEPOLIA_RPC_URL, VITE_MAINNET_RPC_URL: import.meta.env.VITE_MAINNET_RPC_URL, VITE_STELLAR_RPC_URL: import.meta.env.VITE_STELLAR_RPC_URL, + VITE_HORIZON_URL: import.meta.env.VITE_HORIZON_URL, VITE_STELLAR_NETWORK: import.meta.env.VITE_STELLAR_NETWORK, VITE_CREATOR_KEYS_CONTRACT_ID: import.meta.env.VITE_CREATOR_KEYS_CONTRACT_ID, VITE_ADMIN_WALLETS: import.meta.env.VITE_ADMIN_WALLETS,