diff --git a/apps/frontend/components/stellar/TransactionTracker.test.tsx b/apps/frontend/components/stellar/TransactionTracker.test.tsx new file mode 100644 index 0000000..36adabd --- /dev/null +++ b/apps/frontend/components/stellar/TransactionTracker.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import TransactionTracker from './TransactionTracker' + +describe('TransactionTracker', () => { + const OLD_FETCH = global.fetch + + afterEach(() => { + global.fetch = OLD_FETCH + jest.useRealTimers() + }) + + test('shows pending then confirmed when horizon returns 404 then success', async () => { + jest.useFakeTimers() + + let call = 0 + // first call -> 404, second call -> successful + global.fetch = jest.fn().mockImplementation(() => { + call += 1 + if (call === 1) { + return Promise.resolve({ status: 404, ok: false, text: async () => '' }) + } + return Promise.resolve({ status: 200, ok: true, json: async () => ({ successful: true }) }) + }) + + render() + + // initial check -> 404 => pending + await waitFor(() => expect(screen.getByText(/Current:/)).toHaveTextContent('pending'), { timeout: 500 }) + + // advance timers so the next poll runs + jest.advanceTimersByTime(60) + + await waitFor(() => expect(screen.getByText(/Current:/)).toHaveTextContent('confirmed'), { timeout: 1000 }) + }) + + test('shows failed when horizon returns error', async () => { + global.fetch = jest.fn().mockResolvedValue({ status: 500, ok: false, text: async () => 'server error' }) + + render() + + await waitFor(() => expect(screen.getByText(/Current:/)).toHaveTextContent('failed'), { timeout: 1000 }) + expect(screen.getByText(/server error/)).toBeInTheDocument() + }) +}) diff --git a/apps/frontend/components/stellar/TransactionTracker.tsx b/apps/frontend/components/stellar/TransactionTracker.tsx new file mode 100644 index 0000000..f817212 --- /dev/null +++ b/apps/frontend/components/stellar/TransactionTracker.tsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react' + +type Network = 'testnet' | 'public' + +type Props = { + txHash: string + network?: Network + pollInterval?: number + onStatusChange?: (status: TransactionStatus) => void +} + +export type TransactionStatus = 'submitted' | 'pending' | 'confirmed' | 'failed' + +const HORIZON = { + testnet: 'https://horizon-testnet.stellar.org', + public: 'https://horizon.stellar.org', +} + +export default function TransactionTracker({ + txHash, + network = 'testnet', + pollInterval = 3000, + onStatusChange, +}: Props) { + const [status, setStatus] = useState('submitted') + const [error, setError] = useState(null) + + useEffect(() => { + if (!txHash) return + + let mounted = true + let intervalId: any = null + + async function check() { + try { + const url = `${HORIZON[network]}/transactions/${txHash}` + const res = await fetch(url) + + if (!mounted) return + + if (res.status === 404) { + // transaction not found yet -> pending + setStatus((s) => (s === 'submitted' ? 'pending' : s)) + onStatusChange?.('pending') + return + } + + if (!res.ok) { + const text = await res.text() + setError(`Horizon error: ${res.status} ${text}`) + setStatus('failed') + onStatusChange?.('failed') + return + } + + const body = await res.json() + + // Horizon transaction object includes `successful` boolean + if (body && typeof body.successful === 'boolean') { + if (body.successful) { + setStatus('confirmed') + onStatusChange?.('confirmed') + } else { + setStatus('failed') + onStatusChange?.('failed') + setError('Transaction failed on-chain') + } + } else { + // Unexpected, but treat as pending + setStatus('pending') + onStatusChange?.('pending') + } + } catch (err: any) { + if (!mounted) return + setError(err?.message || String(err)) + setStatus('failed') + onStatusChange?.('failed') + } + } + + // initial quick check + check() + intervalId = setInterval(check, pollInterval) + + return () => { + mounted = false + if (intervalId) clearInterval(intervalId) + } + }, [txHash, network, pollInterval, onStatusChange]) + + const explorerBase = network === 'testnet' ? 'https://stellar.expert/explorer/testnet/tx' : 'https://stellar.expert/explorer/public/tx' + const explorerLink = `${explorerBase}/${txHash}` + + return ( +
+
Transaction status
+ +
+ + + + + +
+ +
+ Current: {status} +
+ + {error ?
{error}
: null} + + +
+ ) +} + +function Step({ label, active, failed }: { label: string; active?: boolean; failed?: boolean }) { + return ( +
+
+
{label}
+
+ ) +} + +function Arrow() { + return
+}