diff --git a/components/dashboard/AuditTrailList.tsx b/components/dashboard/AuditTrailList.tsx
new file mode 100644
index 0000000..869e176
--- /dev/null
+++ b/components/dashboard/AuditTrailList.tsx
@@ -0,0 +1,326 @@
+'use client';
+
+import { useRef, type KeyboardEvent } from 'react';
+import { AlertCircle, ArrowDownUp, Check, Copy, Inbox, RefreshCw } from 'lucide-react';
+import { useAuditTrail } from '@/hooks/useAuditTrail';
+import type { AuditTrailEvent, AuditTrailMetadataValue } from '@/types/auditTrail';
+
+const SKELETON_COUNT = 3;
+
+interface AuditTrailListProps {
+ /** Delivery whose on-chain events are listed. */
+ deliveryId: string | null;
+ className?: string;
+}
+
+/** "escrow_funded" / "escrow-funded" → "Escrow Funded". */
+function formatEventType(eventType: string): string {
+ return eventType
+ .split(/[_\-\s]+/)
+ .filter(Boolean)
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ');
+}
+
+function shorten(value: string, edge = 6): string {
+ return value.length > edge * 2 + 1 ? `${value.slice(0, edge)}…${value.slice(-edge)}` : value;
+}
+
+function formatTimestamp(iso: string): string {
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return 'Unknown time';
+ return date.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+}
+
+function formatMetadataKey(key: string): string {
+ return formatEventType(key.replace(/([a-z])([A-Z])/g, '$1 $2'));
+}
+
+function formatMetadataValue(value: AuditTrailMetadataValue): string {
+ if (value === null) return '—';
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
+ return String(value);
+}
+
+function SkeletonCard() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+interface EventCardProps {
+ event: AuditTrailEvent;
+ isCopied: boolean;
+ onCopy: (_eventId: string) => void;
+ cardRef: (_node: HTMLElement | null) => void;
+}
+
+function EventCard({ event, isCopied, onCopy, cardRef }: EventCardProps) {
+ const titleId = `audit-event-${event.eventId}`;
+ const metadata = Object.entries(event.metadata ?? {});
+
+ return (
+
+
+
+
+ {formatEventType(event.eventType)}
+
+
+
+
+
+
+
- Actor
+ -
+ {event.actor.displayName && {event.actor.displayName}}
+
+ {shorten(event.actor.address)}
+
+ {event.actor.role && (
+
+ {event.actor.role}
+
+ )}
+
+
+ {event.txHash && (
+
+
- Transaction
+ -
+ {shorten(event.txHash, 8)}
+
+
+ )}
+ {event.ledger !== undefined && (
+
+
- Ledger
+ -
+ {event.ledger.toLocaleString('en-US')}
+
+
+ )}
+
+
+ {metadata.length > 0 && (
+
+ {metadata.map(([key, value]) => (
+
+
- {formatMetadataKey(key)}
+ -
+ {formatMetadataValue(value)}
+
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * AuditTrailList — read-only list of immutable blockchain events for a delivery.
+ *
+ * Layered Architecture:
+ * AuditTrailList (Component) → useAuditTrail (Hook) → auditTrailService (Service)
+ *
+ * Cards are keyboard navigable: Tab moves through them, and Arrow Up/Down,
+ * Home and End move focus between cards.
+ */
+export function AuditTrailList({ deliveryId, className = '' }: AuditTrailListProps) {
+ const {
+ events,
+ sortOrder,
+ toggleSortOrder,
+ isLoading,
+ isRefetching,
+ error,
+ retry,
+ copiedEventId,
+ copyEventId,
+ } = useAuditTrail(deliveryId);
+ const cardRefs = useRef<(HTMLElement | null)[]>([]);
+
+ const handleListKeyDown = (e: KeyboardEvent) => {
+ const cards = cardRefs.current.slice(0, events.length);
+ const current = cards.findIndex((card) => card === e.target);
+ if (current === -1) return;
+
+ const last = cards.length - 1;
+ const next: Record = {
+ ArrowDown: Math.min(current + 1, last),
+ ArrowUp: Math.max(current - 1, 0),
+ Home: 0,
+ End: last,
+ };
+ if (!(e.key in next)) return;
+ e.preventDefault();
+ cards[next[e.key]]?.focus();
+ };
+
+ const sortLabel = sortOrder === 'newest' ? 'Newest first' : 'Oldest first';
+
+ return (
+
+
+
+
+ Audit Trail
+
+
+ Immutable smart contract events for this delivery
+
+
+ {deliveryId && (
+
+
+
+
+ )}
+
+
+ {!deliveryId && (
+
+ Select a delivery to view its audit trail.
+
+ )}
+
+ {deliveryId && isLoading && (
+ <>
+
+ Loading audit events
+
+
+ {Array.from({ length: SKELETON_COUNT }, (_, i) => (
+
+ ))}
+
+ >
+ )}
+
+ {deliveryId && !isLoading && error && (
+
+
+
{error}
+
+
+ )}
+
+ {deliveryId && !isLoading && !error && events.length === 0 && (
+
+
+
+
No events recorded yet
+
+ Contract events appear here once they are confirmed on-chain.
+
+
+
+
+ )}
+
+ {deliveryId && !isLoading && !error && events.length > 0 && (
+
+ {events.map((event, index) => (
+ void copyEventId(id)}
+ cardRef={(node) => {
+ cardRefs.current[index] = node;
+ }}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/dashboard/__tests__/AuditTrailList.test.tsx b/components/dashboard/__tests__/AuditTrailList.test.tsx
new file mode 100644
index 0000000..4e8beb2
--- /dev/null
+++ b/components/dashboard/__tests__/AuditTrailList.test.tsx
@@ -0,0 +1,200 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ReactElement } from 'react';
+import { AuditTrailList } from '@/components/dashboard/AuditTrailList';
+import { auditTrailService } from '@/services/auditTrailService';
+import { auditTrailEventsFixture } from '@/hooks/__tests__/fixtures/auditTrailApiResponses';
+
+// Only the network boundary is mocked; useAuditTrail runs for real so these
+// tests exercise the Component -> Hook -> Service path.
+jest.mock('@/services/auditTrailService', () => ({
+ auditTrailService: { getDeliveryEvents: jest.fn() },
+}));
+
+jest.mock('sonner', () => ({
+ toast: { error: jest.fn(), success: jest.fn() },
+}));
+
+const mockGetEvents = auditTrailService.getDeliveryEvents as jest.Mock;
+const writeText = jest.fn();
+
+function renderWithClient(ui: ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ return render({ui});
+}
+
+async function renderLoaded() {
+ const utils = renderWithClient();
+ const list = await screen.findByRole('list', { name: /audit trail events/i });
+ return { ...utils, list };
+}
+
+const cardTitles = (list: HTMLElement) =>
+ within(list)
+ .getAllByRole('article')
+ .map((card) => within(card).getByRole('heading').textContent);
+
+describe('AuditTrailList', () => {
+ beforeAll(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ });
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ writeText.mockResolvedValue(undefined);
+ mockGetEvents.mockResolvedValue(auditTrailEventsFixture);
+ });
+
+ describe('chronological ordering', () => {
+ it('renders events newest first', async () => {
+ const { list } = await renderLoaded();
+
+ expect(cardTitles(list)).toEqual(['Escrow Funded', 'Driver Assigned', 'Contract Created']);
+ });
+
+ it('reverses to oldest first from the sort toggle', async () => {
+ const { list } = await renderLoaded();
+
+ fireEvent.click(screen.getByRole('button', { name: /sort order: newest first/i }));
+
+ expect(cardTitles(list)).toEqual(['Contract Created', 'Driver Assigned', 'Escrow Funded']);
+ expect(screen.getByRole('button', { name: /sort order: oldest first/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('event cards', () => {
+ it('shows event type, timestamp, actor and metadata', async () => {
+ const { list } = await renderLoaded();
+ const card = within(list).getAllByRole('article')[0];
+
+ expect(within(card).getByRole('heading')).toHaveTextContent('Escrow Funded');
+ expect(card.querySelector('time')).toHaveAttribute('dateTime', '2026-09-20T11:30:00.000Z');
+ expect(within(card).getByText('Acme Imports')).toBeInTheDocument();
+ expect(within(card).getByTitle(auditTrailEventsFixture[1].actor.address)).toBeInTheDocument();
+ expect(within(card).getByText('customer')).toBeInTheDocument();
+ expect(within(card).getByText('51,235,002')).toBeInTheDocument();
+
+ const metadata = within(card).getByLabelText('Event metadata');
+ expect(within(metadata).getByText('Amount')).toBeInTheDocument();
+ expect(within(metadata).getByText('1500')).toBeInTheDocument();
+ expect(within(metadata).getByText('Insured')).toBeInTheDocument();
+ expect(within(metadata).getByText('Yes')).toBeInTheDocument();
+ });
+
+ it('omits the metadata block when an event has none', async () => {
+ const { list } = await renderLoaded();
+ const contractCard = within(list).getAllByRole('article')[2];
+
+ expect(within(contractCard).queryByLabelText('Event metadata')).not.toBeInTheDocument();
+ });
+
+ it('copies the event id to the clipboard', async () => {
+ await renderLoaded();
+
+ const button = screen.getByRole('button', { name: 'Copy event ID evt-0003' });
+ await act(async () => {
+ fireEvent.click(button);
+ });
+
+ expect(writeText).toHaveBeenCalledWith('evt-0003');
+ expect(button).toHaveTextContent('Copied');
+ });
+ });
+
+ describe('loading, empty and error states', () => {
+ it('shows a skeleton card per placeholder while loading', async () => {
+ let resolve: (_value: unknown) => void = () => undefined;
+ mockGetEvents.mockReturnValue(new Promise((r) => (resolve = r)));
+ renderWithClient();
+
+ expect(screen.getByRole('status')).toHaveTextContent('Loading audit events');
+ const skeleton = screen.getByTestId('audit-trail-skeleton');
+ expect(skeleton.querySelectorAll('li')).toHaveLength(3);
+
+ await act(async () => {
+ resolve(auditTrailEventsFixture);
+ });
+ await waitFor(() =>
+ expect(screen.queryByTestId('audit-trail-skeleton')).not.toBeInTheDocument(),
+ );
+ expect(screen.getAllByRole('article')).toHaveLength(3);
+ });
+
+ it('shows an empty state with a retry that refetches', async () => {
+ mockGetEvents.mockResolvedValueOnce([]);
+ renderWithClient();
+
+ expect(await screen.findByText('No events recorded yet')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+
+ expect(await screen.findByRole('list', { name: /audit trail events/i })).toBeInTheDocument();
+ expect(mockGetEvents).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows the error with a retry option', async () => {
+ mockGetEvents.mockRejectedValueOnce(new Error('Indexer unavailable'));
+ renderWithClient();
+
+ const alert = await screen.findByRole('alert');
+ expect(alert).toHaveTextContent('Indexer unavailable');
+
+ fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }));
+ await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+ expect(screen.getAllByRole('article')).toHaveLength(3);
+ });
+
+ it('prompts for a delivery when none is selected', () => {
+ renderWithClient();
+
+ expect(screen.getByText('Select a delivery to view its audit trail.')).toBeInTheDocument();
+ expect(mockGetEvents).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('makes every card focusable', async () => {
+ const { list } = await renderLoaded();
+
+ within(list)
+ .getAllByRole('article')
+ .forEach((card) => expect(card).toHaveAttribute('tabIndex', '0'));
+ });
+
+ it('moves focus with Arrow keys, Home and End', async () => {
+ const { list } = await renderLoaded();
+ const cards = within(list).getAllByRole('article');
+
+ cards[0].focus();
+ fireEvent.keyDown(cards[0], { key: 'ArrowDown' });
+ expect(cards[1]).toHaveFocus();
+
+ fireEvent.keyDown(cards[1], { key: 'End' });
+ expect(cards[2]).toHaveFocus();
+
+ fireEvent.keyDown(cards[2], { key: 'ArrowDown' });
+ expect(cards[2]).toHaveFocus();
+
+ fireEvent.keyDown(cards[2], { key: 'ArrowUp' });
+ expect(cards[1]).toHaveFocus();
+
+ fireEvent.keyDown(cards[1], { key: 'Home' });
+ expect(cards[0]).toHaveFocus();
+ });
+
+ it('leaves arrow keys alone when focus is on a button inside a card', async () => {
+ const { list } = await renderLoaded();
+ const copy = screen.getByRole('button', { name: 'Copy event ID evt-0003' });
+
+ copy.focus();
+ fireEvent.keyDown(copy, { key: 'ArrowDown' });
+
+ expect(copy).toHaveFocus();
+ expect(within(list).getAllByRole('article')[1]).not.toHaveFocus();
+ });
+ });
+});
diff --git a/hooks/__tests__/fixtures/auditTrailApiResponses.ts b/hooks/__tests__/fixtures/auditTrailApiResponses.ts
new file mode 100644
index 0000000..08a2900
--- /dev/null
+++ b/hooks/__tests__/fixtures/auditTrailApiResponses.ts
@@ -0,0 +1,40 @@
+import type { AuditTrailEvent } from '@/types/auditTrail';
+
+/** Deliberately returned out of order to exercise client-side sorting. */
+export const auditTrailEventsFixture: AuditTrailEvent[] = [
+ {
+ eventId: 'evt-0002',
+ eventType: 'driver_assigned',
+ timestamp: '2026-09-20T10:15:00.000Z',
+ actor: {
+ address: 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H',
+ role: 'driver',
+ },
+ txHash: '7d2c1f0e9b8a7968574635241302f1e0d9c8b7a6958473625140f3e2d1c0b9a8',
+ ledger: 51234567,
+ metadata: { vehicleId: 'TRK-204', estimatedPickup: '2026-09-20T12:00:00Z' },
+ },
+ {
+ eventId: 'evt-0003',
+ eventType: 'escrow_funded',
+ timestamp: '2026-09-20T11:30:00.000Z',
+ actor: {
+ address: 'GACV3JHPXU7CKKYIRSTNLPFVFWGCAYDGBVNFNFJDG5BFCPJV7ZRKJSGN',
+ role: 'customer',
+ displayName: 'Acme Imports',
+ },
+ txHash: 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90',
+ ledger: 51235002,
+ metadata: { amount: 1500, asset: 'XLM', insured: true },
+ },
+ {
+ eventId: 'evt-0001',
+ eventType: 'contract_created',
+ timestamp: '2026-09-20T09:00:00.000Z',
+ actor: {
+ address: 'CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5',
+ role: 'contract',
+ },
+ ledger: 51233900,
+ },
+];
diff --git a/hooks/__tests__/useAuditTrail.test.tsx b/hooks/__tests__/useAuditTrail.test.tsx
new file mode 100644
index 0000000..60fb51c
--- /dev/null
+++ b/hooks/__tests__/useAuditTrail.test.tsx
@@ -0,0 +1,150 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createElement, type ReactNode } from 'react';
+import { toast } from 'sonner';
+import { sortAuditEvents, useAuditTrail } from '@/hooks/useAuditTrail';
+import { auditTrailService } from '@/services/auditTrailService';
+import { auditTrailEventsFixture } from './fixtures/auditTrailApiResponses';
+
+jest.mock('@/services/auditTrailService', () => ({
+ auditTrailService: { getDeliveryEvents: jest.fn() },
+}));
+
+jest.mock('sonner', () => ({
+ toast: { error: jest.fn(), success: jest.fn() },
+}));
+
+const mockGetEvents = auditTrailService.getDeliveryEvents as jest.Mock;
+const writeText = jest.fn();
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+ });
+ return function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(QueryClientProvider, { client: queryClient }, children);
+ };
+}
+
+const ids = (events: { eventId: string }[]) => events.map((e) => e.eventId);
+
+describe('useAuditTrail', () => {
+ beforeAll(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ configurable: true,
+ });
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ writeText.mockResolvedValue(undefined);
+ mockGetEvents.mockResolvedValue(auditTrailEventsFixture);
+ });
+
+ it('fetches events for the delivery and tracks loading', async () => {
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+
+ expect(result.current.isLoading).toBe(true);
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(mockGetEvents).toHaveBeenCalledWith('del-42', expect.any(AbortSignal));
+ });
+
+ it('orders events newest first by default', async () => {
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+
+ await waitFor(() => expect(result.current.events).toHaveLength(3));
+ expect(result.current.sortOrder).toBe('newest');
+ expect(ids(result.current.events)).toEqual(['evt-0003', 'evt-0002', 'evt-0001']);
+ });
+
+ it('toggles to oldest first', async () => {
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+ await waitFor(() => expect(result.current.events).toHaveLength(3));
+
+ act(() => result.current.toggleSortOrder());
+
+ expect(result.current.sortOrder).toBe('oldest');
+ expect(ids(result.current.events)).toEqual(['evt-0001', 'evt-0002', 'evt-0003']);
+ });
+
+ it('does not fetch without a delivery id', () => {
+ const { result } = renderHook(() => useAuditTrail(null), { wrapper: createWrapper() });
+
+ expect(mockGetEvents).not.toHaveBeenCalled();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.events).toEqual([]);
+ });
+
+ it('exposes errors and recovers on retry', async () => {
+ mockGetEvents.mockRejectedValueOnce(new Error('Indexer unavailable'));
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+
+ await waitFor(() => expect(result.current.error).toBe('Indexer unavailable'));
+
+ act(() => result.current.retry());
+
+ await waitFor(() => expect(result.current.events).toHaveLength(3));
+ expect(result.current.error).toBeNull();
+ });
+
+ describe('copyEventId', () => {
+ it('writes the event id to the clipboard and marks it copied', async () => {
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+
+ await act(async () => {
+ await result.current.copyEventId('evt-0002');
+ });
+
+ expect(writeText).toHaveBeenCalledWith('evt-0002');
+ expect(result.current.copiedEventId).toBe('evt-0002');
+ });
+
+ it('clears the copied marker after a delay', async () => {
+ jest.useFakeTimers();
+ try {
+ const { result } = renderHook(() => useAuditTrail(null), { wrapper: createWrapper() });
+
+ await act(async () => {
+ await result.current.copyEventId('evt-0002');
+ });
+ expect(result.current.copiedEventId).toBe('evt-0002');
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+ expect(result.current.copiedEventId).toBeNull();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('shows an error toast when the clipboard is unavailable', async () => {
+ writeText.mockRejectedValue(new Error('Permission denied'));
+ const { result } = renderHook(() => useAuditTrail('del-42'), { wrapper: createWrapper() });
+
+ await act(async () => {
+ await result.current.copyEventId('evt-0002');
+ });
+
+ expect(toast.error).toHaveBeenCalledWith('Could not copy the event ID');
+ expect(result.current.copiedEventId).toBeNull();
+ });
+ });
+});
+
+describe('sortAuditEvents', () => {
+ it('keeps events with invalid timestamps last in both orders', () => {
+ const broken = { ...auditTrailEventsFixture[0], eventId: 'evt-bad', timestamp: 'not-a-date' };
+ const events = [broken, ...auditTrailEventsFixture];
+
+ expect(ids(sortAuditEvents(events, 'newest')).at(-1)).toBe('evt-bad');
+ expect(ids(sortAuditEvents(events, 'oldest')).at(-1)).toBe('evt-bad');
+ });
+
+ it('does not mutate the input', () => {
+ const input = [...auditTrailEventsFixture];
+ sortAuditEvents(input, 'newest');
+ expect(input).toEqual(auditTrailEventsFixture);
+ });
+});
diff --git a/services/__tests__/auditTrailService.test.ts b/services/__tests__/auditTrailService.test.ts
new file mode 100644
index 0000000..60f82cd
--- /dev/null
+++ b/services/__tests__/auditTrailService.test.ts
@@ -0,0 +1,70 @@
+import { AxiosError, AxiosHeaders } from 'axios';
+import api from '@/lib/api';
+import { auditTrailService } from '@/services/auditTrailService';
+import { auditTrailEventsFixture } from '@/hooks/__tests__/fixtures/auditTrailApiResponses';
+
+jest.mock('@/lib/api', () => ({
+ __esModule: true,
+ default: { get: jest.fn() },
+}));
+
+const mockGet = api.get as jest.Mock;
+
+describe('auditTrailService.getDeliveryEvents', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('reads the delivery events endpoint and returns the events', async () => {
+ mockGet.mockResolvedValue({
+ data: { success: true, data: { deliveryId: 'del-42', events: auditTrailEventsFixture } },
+ });
+
+ await expect(auditTrailService.getDeliveryEvents('del-42')).resolves.toEqual(
+ auditTrailEventsFixture,
+ );
+ expect(mockGet).toHaveBeenCalledWith('/audit/delivery/del-42/events', { signal: undefined });
+ });
+
+ it('encodes the delivery id', async () => {
+ mockGet.mockResolvedValue({ data: { success: true, data: { deliveryId: 'x', events: [] } } });
+
+ await auditTrailService.getDeliveryEvents('a/b');
+
+ expect(mockGet).toHaveBeenCalledWith('/audit/delivery/a%2Fb/events', { signal: undefined });
+ });
+
+ it('throws the API message for an unsuccessful response', async () => {
+ mockGet.mockResolvedValue({ data: { success: false, message: 'Delivery not found' } });
+
+ await expect(auditTrailService.getDeliveryEvents('missing')).rejects.toThrow(
+ 'Delivery not found',
+ );
+ });
+
+ it('uses the server error message from an HTTP failure', async () => {
+ const headers = new AxiosHeaders();
+ mockGet.mockRejectedValue(
+ new AxiosError('Request failed', 'ERR_BAD_RESPONSE', { headers }, null, {
+ status: 502,
+ statusText: 'Bad Gateway',
+ headers,
+ config: { headers },
+ data: { message: 'Indexer unavailable' },
+ }),
+ );
+
+ await expect(auditTrailService.getDeliveryEvents('del-42')).rejects.toThrow(
+ 'Indexer unavailable',
+ );
+ });
+
+ it('falls back to a readable message when the error has no detail', async () => {
+ const headers = new AxiosHeaders();
+ mockGet.mockRejectedValue(new AxiosError('timeout', 'ECONNABORTED', { headers }));
+
+ await expect(auditTrailService.getDeliveryEvents('del-42')).rejects.toThrow(
+ 'Unable to load the audit trail. Please try again.',
+ );
+ });
+});
diff --git a/services/auditTrailService.ts b/services/auditTrailService.ts
new file mode 100644
index 0000000..1e232e1
--- /dev/null
+++ b/services/auditTrailService.ts
@@ -0,0 +1,34 @@
+import { isAxiosError } from 'axios';
+import api from '@/lib/api';
+import type { AuditTrailEvent, AuditTrailResponse } from '@/types/auditTrail';
+
+/**
+ * auditTrailService — reads the immutable on-chain event log of a delivery.
+ * Hooks call this; components never call this directly.
+ *
+ * Errors are thrown (not folded into the response) so query hooks can track
+ * them as error state.
+ */
+export const auditTrailService = {
+ async getDeliveryEvents(deliveryId: string, signal?: AbortSignal): Promise {
+ const fallback = 'Unable to load the audit trail. Please try again.';
+ let response: AuditTrailResponse;
+ try {
+ const { data } = await api.get(
+ `/audit/delivery/${encodeURIComponent(deliveryId)}/events`,
+ { signal },
+ );
+ response = data;
+ } catch (error) {
+ if (isAxiosError<{ message?: string }>(error)) {
+ throw new Error(error.response?.data?.message ?? fallback);
+ }
+ throw error instanceof Error ? error : new Error(fallback);
+ }
+
+ if (!response.success || !response.data) {
+ throw new Error(response.message ?? fallback);
+ }
+ return response.data.events ?? [];
+ },
+};
diff --git a/types/auditTrail.ts b/types/auditTrail.ts
new file mode 100644
index 0000000..39fd84e
--- /dev/null
+++ b/types/auditTrail.ts
@@ -0,0 +1,40 @@
+/**
+ * Immutable blockchain events recorded against a delivery's escrow contract,
+ * returned by GET /api/audit/delivery/{deliveryId}/events.
+ */
+
+export type AuditTrailMetadataValue = string | number | boolean | null;
+
+export interface AuditTrailActor {
+ /** Stellar public key (G...) or contract address (C...) that emitted the event. */
+ address: string;
+ /** Role on the delivery, e.g. "customer", "driver", "admin" or "contract". */
+ role?: string;
+ displayName?: string;
+}
+
+export interface AuditTrailEvent {
+ /** Stable event identifier, unique per contract event. */
+ eventId: string;
+ /** Event type as emitted by the contract, e.g. "escrow_funded". */
+ eventType: string;
+ /** ISO 8601 ledger close time of the event. */
+ timestamp: string;
+ actor: AuditTrailActor;
+ /** Hash of the transaction that emitted the event. */
+ txHash?: string;
+ /** Ledger sequence the event was included in. */
+ ledger?: number;
+ metadata?: Record;
+}
+
+export type AuditTrailSortOrder = 'newest' | 'oldest';
+
+export interface AuditTrailResponse {
+ success: boolean;
+ message?: string;
+ data?: {
+ deliveryId: string;
+ events: AuditTrailEvent[];
+ };
+}