diff --git a/features/transactions/components/TransactionHistoryGrid.tsx b/features/transactions/components/TransactionHistoryGrid.tsx
new file mode 100644
index 0000000..feef7a3
--- /dev/null
+++ b/features/transactions/components/TransactionHistoryGrid.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { Inbox } 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 TransactionHistoryGridProps {
+ transactions: CrossBorderTransaction[];
+ onSelect?: (_transaction: CrossBorderTransaction) => void;
+}
+
+/**
+ * Card grid view of a user's cross-border transactions.
+ *
+ * The same data as the list view, laid out as scannable cards. Every displayed
+ * value goes through the shared formatters so a card and a row can never
+ * disagree about how an amount or an escrow status reads.
+ */
+export function TransactionHistoryGrid({
+ transactions,
+ onSelect,
+}: TransactionHistoryGridProps) {
+ if (transactions.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {transactions.map((transaction) => {
+ const escrowLabel = formatEscrowStatus(transaction.escrowStatus);
+
+ return (
+
+
+
+
+
+ {formatSignedAssetAmount(
+ transaction.amount,
+ transaction.assetCode,
+ transaction.direction,
+ )}
+
+
+ {formatFiatAmount(
+ transaction.fiatAmount,
+ transaction.fiatCurrency,
+ )}
+
+
+
+
+ {formatCorridor(
+ transaction.originCountry,
+ transaction.destinationCountry,
+ )}
+
+
+ {formatTransactionDate(transaction.createdAt)}
+
+
+
+ {onSelect && (
+ onSelect(transaction)}
+ className="mt-4 rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
+ >
+ View details
+
+ )}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx
new file mode 100644
index 0000000..fab4875
--- /dev/null
+++ b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx
@@ -0,0 +1,300 @@
+/**
+ * TransactionHistoryGrid component tests.
+ *
+ * The focus is data formatting inside the cards: currency amounts must be
+ * grouped, signed by direction and shown with their asset/ISO code, and escrow
+ * statuses must render as human-readable badges rather than raw wire values.
+ */
+
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { TransactionHistoryGrid } from '@/features/transactions/components/TransactionHistoryGrid';
+import type { CrossBorderTransaction } from '@/types/transactionHistory';
+import type { EscrowStatus } from '@/types/status';
+
+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 cards() {
+ return screen.queryAllByTestId('transaction-card');
+}
+
+function firstCard() {
+ return within(cards()[0]);
+}
+
+describe('TransactionHistoryGrid', () => {
+ describe('currency amount formatting', () => {
+ it('groups thousands and pads to two decimals', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-amount')).toHaveTextContent(
+ '-1,250.50 XLM',
+ );
+ });
+
+ it('marks outgoing transfers with a minus and incoming with a plus', () => {
+ render(
+ ,
+ );
+
+ const amounts = screen
+ .getAllByTestId('card-amount')
+ .map((el) => el.textContent);
+ expect(amounts).toEqual(['-1,250.50 XLM', '+800.00 XLM']);
+ });
+
+ it('shows the settlement asset code alongside the amount', () => {
+ render(
+ ,
+ );
+
+ const amounts = screen
+ .getAllByTestId('card-amount')
+ .map((el) => el.textContent);
+ expect(amounts).toEqual(['-500.00 USDC', '-500.00 XLM']);
+ });
+
+ it('keeps stellar precision for sub-cent amounts', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-amount')).toHaveTextContent(
+ '+0.0000001 XLM',
+ );
+ });
+
+ it('formats a large amount without scientific notation', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-amount')).toHaveTextContent(
+ '+12,345,678.90 XLM',
+ );
+ });
+
+ it('renders the local currency leg with its ISO code', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent(
+ '1,234,567.80 NGN',
+ );
+ });
+
+ it('renders a placeholder when no fiat quote is attached', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent('—');
+ });
+
+ it('never prints NaN when the feed sends a bad amount', () => {
+ render(
+ ,
+ );
+
+ const amount = firstCard().getByTestId('card-amount');
+ expect(amount).toHaveTextContent('—');
+ expect(amount.textContent).not.toMatch(/NaN/);
+ });
+
+ it('renders a zero-value transfer as a real amount', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-amount')).toHaveTextContent(
+ '-0.00 XLM',
+ );
+ expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent(
+ '0.00 USD',
+ );
+ });
+ });
+
+ describe('escrow status formatting', () => {
+ it.each<[EscrowStatus, string]>([
+ ['LOCKED', 'Locked in escrow'],
+ ['RELEASED', 'Released'],
+ ['DISPUTED', 'Disputed'],
+ ['NOT_LOCKED', 'Not locked'],
+ ])('renders %s as the readable label %s', (escrowStatus, expected) => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-escrow-status')).toHaveTextContent(
+ expected,
+ );
+ });
+
+ it('never shows the raw underscored wire value', () => {
+ render(
+ ,
+ );
+
+ expect(cards()[0].textContent).not.toContain('NOT_LOCKED');
+ });
+
+ it('gives the badge an accessible label', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByLabelText('Escrow status: Disputed'),
+ ).toBeInTheDocument();
+ });
+
+ it('styles each status distinctly so they are visually separable', () => {
+ render(
+ ,
+ );
+
+ const classNames = screen
+ .getAllByTestId('card-escrow-status')
+ .map((el) => el.className);
+ expect(new Set(classNames).size).toBe(3);
+ });
+ });
+
+ describe('card content', () => {
+ it('renders one card per transaction in the given order', () => {
+ render(
+ ,
+ );
+
+ const rendered = cards();
+ expect(rendered).toHaveLength(2);
+ expect(rendered[0]).toHaveTextContent('Amara Okafor');
+ expect(rendered[1]).toHaveTextContent('Wei Zhang');
+ });
+
+ it('shows the reference, corridor and date', () => {
+ render( );
+
+ const card = firstCard();
+ expect(card.getByTestId('card-reference')).toHaveTextContent(
+ 'SC-20260425-0001',
+ );
+ expect(card.getByTestId('card-corridor')).toHaveTextContent('NG → GB');
+ expect(card.getByTestId('card-date')).toHaveTextContent('Apr 25, 2026');
+ });
+
+ it('renders a placeholder for an unparseable timestamp', () => {
+ render(
+ ,
+ );
+
+ expect(firstCard().getByTestId('card-date')).toHaveTextContent('—');
+ });
+
+ it('labels the grid for assistive technology', () => {
+ render( );
+
+ expect(
+ screen.getByRole('list', { name: 'Cross-border transactions' }),
+ ).toBeInTheDocument();
+ });
+
+ it('passes the selected transaction to the details handler', async () => {
+ const user = userEvent.setup();
+ const onSelect = jest.fn();
+ const tx = transaction({ id: 'tx-9' });
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'View details' }));
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onSelect).toHaveBeenCalledWith(tx);
+ });
+
+ it('omits the details action when no handler is supplied', () => {
+ render( );
+
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('empty state', () => {
+ it('renders an empty state instead of a bare grid', () => {
+ render( );
+
+ expect(
+ screen.getByText('No cross-border transactions yet'),
+ ).toBeInTheDocument();
+ expect(cards()).toHaveLength(0);
+ expect(screen.queryByRole('list')).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/lib/__tests__/transactionFormatters.test.ts b/lib/__tests__/transactionFormatters.test.ts
new file mode 100644
index 0000000..94dd7ac
--- /dev/null
+++ b/lib/__tests__/transactionFormatters.test.ts
@@ -0,0 +1,152 @@
+/**
+ * Unit tests for the cross-border transaction display formatters.
+ *
+ * These are the single source of truth for how amounts and escrow statuses
+ * read in both the grid and list views, so they are pinned down directly as
+ * well as through the component tests.
+ */
+
+import {
+ escrowStatusStyle,
+ ESCROW_STATUS_STYLES,
+ formatAssetAmount,
+ formatCorridor,
+ formatEscrowStatus,
+ formatFiatAmount,
+ formatSignedAssetAmount,
+ formatTransactionDate,
+} from '@/lib/transactionFormatters';
+import type { EscrowStatus } from '@/types/status';
+
+describe('formatAssetAmount', () => {
+ it('groups thousands and pads to two decimals', () => {
+ expect(formatAssetAmount(1250.5, 'XLM')).toBe('1,250.50 XLM');
+ expect(formatAssetAmount(1000000, 'USDC')).toBe('1,000,000.00 USDC');
+ });
+
+ it('keeps stellar precision up to seven decimal places', () => {
+ expect(formatAssetAmount(0.0000001, 'XLM')).toBe('0.0000001 XLM');
+ expect(formatAssetAmount(12.1234567, 'XLM')).toBe('12.1234567 XLM');
+ });
+
+ it('rounds beyond seven decimal places rather than overflowing the cell', () => {
+ expect(formatAssetAmount(1.123456789, 'XLM')).toBe('1.1234568 XLM');
+ });
+
+ it('formats zero as a real amount, not a placeholder', () => {
+ expect(formatAssetAmount(0, 'XLM')).toBe('0.00 XLM');
+ });
+
+ it('preserves the sign of a negative amount', () => {
+ expect(formatAssetAmount(-42.5, 'XLM')).toBe('-42.50 XLM');
+ });
+
+ it('renders a placeholder instead of NaN or Infinity', () => {
+ expect(formatAssetAmount(Number.NaN, 'XLM')).toBe('—');
+ expect(formatAssetAmount(Number.POSITIVE_INFINITY, 'XLM')).toBe('—');
+ });
+});
+
+describe('formatFiatAmount', () => {
+ it('renders the amount with its ISO currency code', () => {
+ expect(formatFiatAmount(640.25, 'USD')).toBe('640.25 USD');
+ expect(formatFiatAmount(1234567.8, 'NGN')).toBe('1,234,567.80 NGN');
+ });
+
+ it('always shows exactly two decimals', () => {
+ expect(formatFiatAmount(10, 'EUR')).toBe('10.00 EUR');
+ expect(formatFiatAmount(10.129, 'EUR')).toBe('10.13 EUR');
+ });
+
+ it('returns a placeholder when the corridor has no fiat quote', () => {
+ expect(formatFiatAmount(undefined, 'USD')).toBe('—');
+ expect(formatFiatAmount(640.25, undefined)).toBe('—');
+ expect(formatFiatAmount(undefined, undefined)).toBe('—');
+ });
+
+ it('returns a placeholder for a non-finite amount', () => {
+ expect(formatFiatAmount(Number.NaN, 'USD')).toBe('—');
+ });
+
+ it('treats a zero quote as a real amount', () => {
+ expect(formatFiatAmount(0, 'USD')).toBe('0.00 USD');
+ });
+});
+
+describe('formatSignedAssetAmount', () => {
+ it('prefixes outgoing transfers with a minus sign', () => {
+ expect(formatSignedAssetAmount(1250.5, 'XLM', 'SENT')).toBe('-1,250.50 XLM');
+ });
+
+ it('prefixes incoming transfers with a plus sign', () => {
+ expect(formatSignedAssetAmount(800, 'XLM', 'RECEIVED')).toBe('+800.00 XLM');
+ });
+
+ it('signs a zero amount by direction rather than dropping the sign', () => {
+ expect(formatSignedAssetAmount(0, 'USDC', 'SENT')).toBe('-0.00 USDC');
+ expect(formatSignedAssetAmount(0, 'USDC', 'RECEIVED')).toBe('+0.00 USDC');
+ });
+
+ it('does not sign the placeholder for a non-finite amount', () => {
+ expect(formatSignedAssetAmount(Number.NaN, 'XLM', 'SENT')).toBe('—');
+ });
+});
+
+describe('formatEscrowStatus', () => {
+ it.each<[EscrowStatus, string]>([
+ ['LOCKED', 'Locked in escrow'],
+ ['RELEASED', 'Released'],
+ ['DISPUTED', 'Disputed'],
+ ['NOT_LOCKED', 'Not locked'],
+ ])('renders %s as %s', (status, expected) => {
+ expect(formatEscrowStatus(status)).toBe(expected);
+ });
+
+ it('never leaks the raw wire format to the user', () => {
+ const statuses: EscrowStatus[] = [
+ 'LOCKED',
+ 'RELEASED',
+ 'DISPUTED',
+ 'NOT_LOCKED',
+ ];
+
+ for (const status of statuses) {
+ expect(formatEscrowStatus(status)).not.toContain('_');
+ }
+ });
+
+ it('falls back to Unknown for an unrecognised status', () => {
+ expect(formatEscrowStatus('SOMETHING_NEW' as EscrowStatus)).toBe('Unknown');
+ });
+});
+
+describe('escrowStatusStyle', () => {
+ it('gives every known status a distinct badge style', () => {
+ const styles = Object.values(ESCROW_STATUS_STYLES);
+ expect(new Set(styles).size).toBe(styles.length);
+ });
+
+ it('returns the neutral style for an unrecognised status', () => {
+ expect(escrowStatusStyle('SOMETHING_NEW' as EscrowStatus)).toBe(
+ ESCROW_STATUS_STYLES.NOT_LOCKED,
+ );
+ });
+});
+
+describe('formatCorridor', () => {
+ it('renders origin and destination with a direction arrow', () => {
+ expect(formatCorridor('NG', 'GB')).toBe('NG → GB');
+ });
+});
+
+describe('formatTransactionDate', () => {
+ it('renders a short, unambiguous date', () => {
+ expect(formatTransactionDate('2026-04-25T12:00:00.000Z')).toBe(
+ 'Apr 25, 2026',
+ );
+ });
+
+ it('returns a placeholder for an unparseable timestamp', () => {
+ expect(formatTransactionDate('not-a-date')).toBe('—');
+ });
+});
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;
+}