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/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__/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/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'); + }); +}); 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/__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(/-/); + }); }); 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,