diff --git a/features/transactions/components/TransactionHistoryList.tsx b/features/transactions/components/TransactionHistoryList.tsx
new file mode 100644
index 0000000..245d6cf
--- /dev/null
+++ b/features/transactions/components/TransactionHistoryList.tsx
@@ -0,0 +1,177 @@
+'use client';
+
+import { ArrowLeftRight, SearchX, Send } from 'lucide-react';
+import { EmptyState } from '@/components/ui/EmptyState';
+import {
+ escrowStatusStyle,
+ formatCorridor,
+ formatEscrowStatus,
+ formatFiatAmount,
+ formatSignedAssetAmount,
+ formatTransactionDate,
+} from '@/lib/transactionFormatters';
+import type { CrossBorderTransaction } from '@/types/transactionHistory';
+
+interface TransactionHistoryListProps {
+ transactions: CrossBorderTransaction[];
+ isLoading?: boolean;
+ /** Message to surface when the history could not be loaded. */
+ error?: string | null;
+ /**
+ * Whether a search/filter is narrowing the list. Distinguishes "you have no
+ * transactions yet" from "nothing matched", which need different copy and
+ * different calls to action.
+ */
+ hasActiveFilters?: boolean;
+ onClearFilters?: () => void;
+ onCreateTransfer?: () => void;
+ onRetry?: () => void;
+}
+
+/**
+ * Chronological list view of a user's cross-border transactions.
+ *
+ * Renders four mutually exclusive states: loading, error, empty, and populated.
+ * The empty state is deliberately split in two, because a first-time user needs
+ * an invitation to send a transfer while a user who has over-filtered needs a
+ * way back to the full list.
+ */
+export function TransactionHistoryList({
+ transactions,
+ isLoading = false,
+ error = null,
+ hasActiveFilters = false,
+ onClearFilters,
+ onCreateTransfer,
+ onRetry,
+}: TransactionHistoryListProps) {
+ if (isLoading) {
+ return (
+
+ {[0, 1, 2].map((row) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
{error}
+ {onRetry && (
+
+ Try again
+
+ )}
+
+ );
+ }
+
+ if (transactions.length === 0) {
+ if (hasActiveFilters) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ return (
+
+ {transactions.map((transaction) => (
+
+
+
+
+
+ {transaction.counterparty}
+
+
+ {transaction.reference} ·{' '}
+ {formatCorridor(
+ transaction.originCountry,
+ transaction.destinationCountry,
+ )}
+
+
+ {formatTransactionDate(transaction.createdAt)}
+
+
+
+
+
+ {formatSignedAssetAmount(
+ transaction.amount,
+ transaction.assetCode,
+ transaction.direction,
+ )}
+
+
+ {formatFiatAmount(
+ transaction.fiatAmount,
+ transaction.fiatCurrency,
+ )}
+
+
+ {formatEscrowStatus(transaction.escrowStatus)}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/features/transactions/components/__tests__/TransactionHistoryList.test.tsx b/features/transactions/components/__tests__/TransactionHistoryList.test.tsx
new file mode 100644
index 0000000..151acdf
--- /dev/null
+++ b/features/transactions/components/__tests__/TransactionHistoryList.test.tsx
@@ -0,0 +1,287 @@
+/**
+ * TransactionHistoryList component tests.
+ *
+ * The focus is the empty states: a first-time user with no cross-border
+ * transactions must get a friendly, actionable message rather than a blank
+ * panel, and an over-filtered list must offer a way back. Loading, error and
+ * populated states are covered so the four states stay mutually exclusive.
+ */
+
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TransactionHistoryList } from '@/features/transactions/components/TransactionHistoryList';
+import type { CrossBorderTransaction } from '@/types/transactionHistory';
+
+function transaction(
+ overrides: Partial = {},
+): CrossBorderTransaction {
+ return {
+ id: 'tx-1',
+ reference: 'SC-20260425-0001',
+ direction: 'SENT',
+ counterparty: 'Amara Okafor',
+ amount: 1250.5,
+ assetCode: 'XLM',
+ fiatAmount: 640.25,
+ fiatCurrency: 'USD',
+ originCountry: 'NG',
+ destinationCountry: 'GB',
+ escrowStatus: 'LOCKED',
+ createdAt: '2026-04-25T12:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function rows() {
+ return screen.queryAllByTestId('transaction-row');
+}
+
+describe('TransactionHistoryList', () => {
+ describe('empty state - no transactions at all', () => {
+ it('renders a friendly empty state instead of an empty list', () => {
+ render( );
+
+ expect(
+ screen.getByText('No cross-border transactions yet'),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/Once you send or receive your first cross-border transfer/i),
+ ).toBeInTheDocument();
+ expect(rows()).toHaveLength(0);
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ });
+
+ it('offers a call to action that starts the first transfer', async () => {
+ const user = userEvent.setup();
+ const onCreateTransfer = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole('button', { name: 'Send your first transfer' }),
+ );
+
+ expect(onCreateTransfer).toHaveBeenCalledTimes(1);
+ });
+
+ it('omits the call to action when no handler is supplied', () => {
+ render( );
+
+ expect(
+ screen.getByText('No cross-border transactions yet'),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('does not show the filtered empty state copy', () => {
+ render( );
+
+ expect(
+ screen.queryByText('No transactions match your filters'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe('empty state - filters exclude everything', () => {
+ it('explains that filters, not the account, are the reason', () => {
+ render( );
+
+ expect(
+ screen.getByText('No transactions match your filters'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByText('No cross-border transactions yet'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('offers a way back to the full history', async () => {
+ const user = userEvent.setup();
+ const onClearFilters = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
+
+ expect(onClearFilters).toHaveBeenCalledTimes(1);
+ });
+
+ it('ignores the filtered variant once results exist again', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByText('No transactions match your filters'),
+ ).not.toBeInTheDocument();
+ expect(rows()).toHaveLength(1);
+ });
+ });
+
+ describe('loading state', () => {
+ it('shows skeleton placeholders and no empty state', () => {
+ render( );
+
+ expect(screen.getAllByTestId('transaction-skeleton')).toHaveLength(3);
+ expect(
+ screen.queryByText('No cross-border transactions yet'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('announces loading to assistive technology', () => {
+ render( );
+
+ const status = screen.getByRole('status');
+ expect(status).toHaveAttribute('aria-label', 'Loading transaction history');
+ expect(status).toHaveAttribute('aria-live', 'polite');
+ });
+
+ it('takes precedence over an error so states never stack', () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.getAllByTestId('transaction-skeleton')).toHaveLength(3);
+ });
+ });
+
+ describe('error state', () => {
+ it('surfaces the failure message as an alert', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Network unreachable');
+ expect(
+ screen.queryByText('No cross-border transactions yet'),
+ ).not.toBeInTheDocument();
+ });
+
+ it('lets the user retry the fetch', async () => {
+ const user = userEvent.setup();
+ const onRetry = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Try again' }));
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('hides the retry button when no handler is supplied', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByRole('button', { name: 'Try again' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('still reports the error even when transactions are cached', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(rows()).toHaveLength(0);
+ });
+ });
+
+ describe('populated list', () => {
+ it('renders one row per transaction in the given order', () => {
+ render(
+ ,
+ );
+
+ const rendered = rows();
+ expect(rendered).toHaveLength(2);
+ expect(rendered[0]).toHaveTextContent('Amara Okafor');
+ expect(rendered[1]).toHaveTextContent('Wei Zhang');
+ });
+
+ it('labels the list for assistive technology', () => {
+ render( );
+
+ expect(
+ screen.getByRole('list', { name: 'Cross-border transactions' }),
+ ).toBeInTheDocument();
+ });
+
+ it('shows the reference, corridor and escrow status of a row', () => {
+ render( );
+
+ const row = within(rows()[0]);
+ expect(row.getByText(/SC-20260425-0001/)).toBeInTheDocument();
+ expect(row.getByText(/NG → GB/)).toBeInTheDocument();
+ expect(row.getByText('Locked in escrow')).toBeInTheDocument();
+ });
+
+ it('signs the amount according to the transfer direction', () => {
+ render(
+ ,
+ );
+
+ expect(rows()[0]).toHaveTextContent('-1,250.50 XLM');
+ expect(rows()[1]).toHaveTextContent('+800.00 XLM');
+ });
+
+ it('renders a placeholder when a corridor has no fiat quote', () => {
+ render(
+ ,
+ );
+
+ expect(rows()[0]).toHaveTextContent('—');
+ });
+
+ it('renders a single-item history without falling back to the empty state', () => {
+ render( );
+
+ expect(rows()).toHaveLength(1);
+ expect(
+ screen.queryByText('No cross-border transactions yet'),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/lib/transactionFormatters.ts b/lib/transactionFormatters.ts
new file mode 100644
index 0000000..6bae4c4
--- /dev/null
+++ b/lib/transactionFormatters.ts
@@ -0,0 +1,102 @@
+/**
+ * Display formatting for cross-border transaction history.
+ *
+ * Amounts are rendered as a locale-grouped number followed by the ISO asset or
+ * currency code rather than a localised symbol. Corridors span currencies whose
+ * symbols collide (several use "$"), and `Intl` currency symbols also vary with
+ * the host ICU build, which would make the UI inconsistent across environments.
+ */
+
+import type { EscrowStatus } from '@/types/status';
+import type { TransactionDirection } from '@/types/transactionHistory';
+
+/** Stellar assets carry up to 7 decimal places. */
+const MAX_ASSET_DECIMALS = 7;
+const FIAT_DECIMALS = 2;
+
+const assetFormatter = new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: FIAT_DECIMALS,
+ maximumFractionDigits: MAX_ASSET_DECIMALS,
+});
+
+const fiatFormatter = new Intl.NumberFormat('en-US', {
+ minimumFractionDigits: FIAT_DECIMALS,
+ maximumFractionDigits: FIAT_DECIMALS,
+});
+
+const ESCROW_STATUS_LABELS: Record = {
+ LOCKED: 'Locked in escrow',
+ RELEASED: 'Released',
+ DISPUTED: 'Disputed',
+ NOT_LOCKED: 'Not locked',
+};
+
+export const ESCROW_STATUS_STYLES: Record = {
+ LOCKED: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
+ RELEASED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
+ DISPUTED:
+ 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
+ NOT_LOCKED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
+};
+
+const UNKNOWN_AMOUNT = '—';
+
+/**
+ * Formats a settlement amount, e.g. `1234.5, "XLM"` becomes `1,234.50 XLM`.
+ * Non-finite amounts render as an em dash so a bad feed cannot print "NaN".
+ */
+export function formatAssetAmount(amount: number, assetCode: string): string {
+ if (!Number.isFinite(amount)) return UNKNOWN_AMOUNT;
+ return `${assetFormatter.format(amount)} ${assetCode}`;
+}
+
+/**
+ * Formats the local-currency leg of a transfer, e.g. `1,234.50 NGN`.
+ * Returns an em dash when the corridor has no fiat quote attached.
+ */
+export function formatFiatAmount(
+ amount: number | undefined,
+ currency: string | undefined,
+): string {
+ if (typeof amount !== 'number' || !Number.isFinite(amount) || !currency) {
+ return UNKNOWN_AMOUNT;
+ }
+ return `${fiatFormatter.format(amount)} ${currency}`;
+}
+
+/** Prefixes an amount with the direction sign a user expects on a ledger. */
+export function formatSignedAssetAmount(
+ amount: number,
+ assetCode: string,
+ direction: TransactionDirection,
+): string {
+ const formatted = formatAssetAmount(amount, assetCode);
+ if (formatted === UNKNOWN_AMOUNT) return formatted;
+ return `${direction === 'SENT' ? '-' : '+'}${formatted}`;
+}
+
+/** Turns the wire-format escrow status into sentence-case display text. */
+export function formatEscrowStatus(status: EscrowStatus): string {
+ return ESCROW_STATUS_LABELS[status] ?? 'Unknown';
+}
+
+/** Tailwind classes for an escrow status badge, with a neutral fallback. */
+export function escrowStatusStyle(status: EscrowStatus): string {
+ return ESCROW_STATUS_STYLES[status] ?? ESCROW_STATUS_STYLES.NOT_LOCKED;
+}
+
+/** Renders a corridor as `NG -> GB`, using an arrow the terminal-safe way. */
+export function formatCorridor(origin: string, destination: string): string {
+ return `${origin} → ${destination}`;
+}
+
+/** Short, unambiguous date for a history row, e.g. `Apr 25, 2026`. */
+export function formatTransactionDate(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return UNKNOWN_AMOUNT;
+ return date.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+}
diff --git a/types/transactionHistory.ts b/types/transactionHistory.ts
new file mode 100644
index 0000000..772462a
--- /dev/null
+++ b/types/transactionHistory.ts
@@ -0,0 +1,34 @@
+/**
+ * Cross-border transaction history types.
+ *
+ * Shared by the list and grid presentations of a user's settled and in-flight
+ * cross-border transfers.
+ */
+
+import type { EscrowStatus } from './status';
+
+export type TransactionDirection = 'SENT' | 'RECEIVED';
+
+export interface CrossBorderTransaction {
+ id: string;
+ /** Human-facing reference shown to the user, e.g. "SC-20260425-0001". */
+ reference: string;
+ direction: TransactionDirection;
+ /** Display name of the other party in the corridor. */
+ counterparty: string;
+ /** Amount in the settlement asset. */
+ amount: number;
+ /** Settlement asset code, e.g. "XLM" or "USDC". */
+ assetCode: string;
+ /** Quoted amount in the corridor's local fiat currency, when available. */
+ fiatAmount?: number;
+ /** ISO 4217 code for {@link fiatAmount}. */
+ fiatCurrency?: string;
+ /** ISO 3166-1 alpha-2 origin country. */
+ originCountry: string;
+ /** ISO 3166-1 alpha-2 destination country. */
+ destinationCountry: string;
+ escrowStatus: EscrowStatus;
+ /** ISO 8601 creation timestamp. */
+ createdAt: string;
+}