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 (
+
+ )
+}
+
+function Arrow() {
+ return →
+}