diff --git a/README.md b/README.md
index 29aaefa..e2e7311 100644
--- a/README.md
+++ b/README.md
@@ -358,6 +358,17 @@ Default branch is `dev`. Open a PR against `dev` for review before merging.
real call once that integration lands. `BookingScreen`'s existing one-shot
"Pay into escrow" button is unchanged; wiring the booking flow to this
wizard is a follow-up.
+- **Escrow status timeline** (`src/components/escrow/EscrowTimelinePanel.tsx`,
+ `src/lib/escrowTimeline.ts`, route `/escrow/[bookingRef]/timeline`): a
+ live, chronological timeline of the on-chain escrow lifecycle
+ (`funded → completed | cancelled | disputed → resolved`, mirroring the
+ Soroban `escrow` contract's `Status`). User actions apply **optimistically**
+ and either confirm into the history or **roll back gracefully** on failure;
+ a visibility-aware background poll keeps it in sync with the authoritative
+ status. The chain calls live in an isolated stub (`src/lib/escrowChain.ts`)
+ for the same reason `fundEscrow()` does — swap its two functions for a real
+ endpoint when the backend Soroban integration lands. See
+ [`docs/escrow-status-timeline.md`](docs/escrow-status-timeline.md).
- The wallet-connect button is a real Freighter connection but doesn't yet
do anything with the connected address — no contract calls, no signing.
That's intentionally scoped to land alongside the backend Soroban
diff --git a/docs/escrow-status-timeline.md b/docs/escrow-status-timeline.md
new file mode 100644
index 0000000..9ecbe52
--- /dev/null
+++ b/docs/escrow-status-timeline.md
@@ -0,0 +1,111 @@
+# Real-Time Escrow Status Timeline with Optimistic Updates
+
+Implements [#26](https://github.com/workman-labs/guildworkman-web/issues/26):
+a live escrow lifecycle timeline that reflects on-chain state changes, applies
+user actions optimistically, and rolls back gracefully on failure.
+
+## What it does
+
+Route `/escrow/[bookingRef]/timeline` renders the escrow's lifecycle as a
+vertical, chronological timeline. From the funded state a client can **release
+funds**, **cancel & refund**, or **raise a dispute**; a dispute can then be
+**resolved**. Each action:
+
+1. appears **instantly** as an optimistic node at the end of the timeline,
+2. **confirms** into the history (with a tx hash + timestamp) once the chain
+ settles it, or
+3. **rolls back** — the optimistic node disappears, the confirmed history is
+ untouched, and an inline error offers a retry.
+
+A background poll keeps the timeline in sync with the authoritative on-chain
+status, so a change made elsewhere (e.g. a counterparty acting) shows up live.
+
+## Lifecycle model
+
+Mirrors the Soroban `escrow` contract's `Status` enum and entrypoints
+(`guildworkman-core/soroban-contracts/contracts/escrow`):
+
+```
+funded ──release──▶ completed (terminal)
+ ──cancel───▶ cancelled (terminal)
+ ──dispute──▶ disputed ──resolve──▶ resolved (terminal)
+```
+
+`actionsFor(status)` is the single source of truth for which actions are legal
+from a given status — it drives both the buttons the UI offers and the
+transitions the reducer accepts.
+
+## Architecture decisions
+
+1. **The timeline is an append-only event log, not a mutable `status`.**
+ `TimelineState.history` is the chronological list of confirmed transitions
+ (always rooted at `funded`); the current status is just its last entry. This
+ is what the UI needs to *draw* a timeline, and it makes the optimistic layer
+ trivially safe: an in-flight action is a single `pending` node appended
+ after the confirmed history, never a mutation of it — so rollback is "drop
+ the pending node" and the confirmed history is untouched by construction.
+
+2. **One pure reducer owns every transition.** `timelineReducer` handles
+ `SUBMIT` / `CONFIRMED` / `FAILED` / `SYNC` / `DISMISS_ERROR` as a pure
+ function the UI and tests drive directly. Optimistic-apply-then-rollback
+ isn't ad-hoc `useState` juggling; it's `SUBMIT` then `FAILED`, both tested.
+ Stale confirmations/failures (superseded by a poll) are ignored by
+ submission-id matching, and `SYNC` reconciles authoritative on-chain state:
+ it commits an in-flight action whose target it observes, adopts a legal
+ external change (dropping any now-impossible optimistic node), and ignores
+ states it can't reconcile.
+
+3. **The only impure part is an isolated, swappable chain stub.**
+ `lib/escrowChain.ts` simulates the contract (latency, an occasional failed
+ submission, an in-memory ledger the poll reads) so the whole feature is
+ exercised today. Same rationale as the funding wizard's `fundEscrow` stub —
+ `guildworkman-core`'s Soroban `escrow` contract has the methods, but no
+ backend REST/RPC endpoint exposes them to the web app yet. Swap the two
+ functions' bodies for real calls and nothing else changes. Keeping this out
+ of `escrowTimeline.ts` keeps the reducer pure and singleton-free for tests.
+
+4. **Real-time via visibility-aware polling.** `useEscrowTimeline` polls every
+ 4s, pauses while the tab is hidden, and polls once immediately on becoming
+ visible again. A future SSE/WebSocket feed can replace the poll behind the
+ same `SYNC` dispatch without touching the reducer or components.
+
+5. **Accessibility.** The optimistic node is an `aria-live` region; confirmed
+ status changes are announced through a separate visually-hidden live region;
+ the rollback error is `role="alert"`. Colours come entirely from the design
+ tokens, so light/dark both work.
+
+## No new dependencies
+
+Uses React (incl. `useSyncExternalStore` for hydration-safe timestamps),
+`react-icons`, and the existing `ui/*` primitives and Tailwind token setup.
+
+## Files
+
+| File | Role |
+|---|---|
+| `src/lib/escrowTimeline.ts` | Pure lifecycle model: statuses, actions, the reducer, and `buildTimelineNodes`. |
+| `src/lib/escrowChain.ts` | Isolated simulated on-chain source (swap for a real endpoint). |
+| `src/components/escrow/useEscrowTimeline.ts` | Hook: wires the reducer to the chain (optimistic submit + polling). |
+| `src/components/escrow/useHydrated.ts` | Hydration-safe "client only" flag for locale timestamps. |
+| `src/components/escrow/EscrowTimeline.tsx` | Presentational vertical timeline. |
+| `src/components/escrow/EscrowTimelinePanel.tsx` | Smart panel: timeline + actions + live-sync + rollback UI. |
+| `src/app/escrow/[bookingRef]/timeline/page.tsx` | Route. |
+| `src/lib/test/escrowTimeline.test.ts` | 22 reducer/metadata unit tests. |
+
+`components/escrow/steps/FundingStatusStep.tsx` gained a "Track escrow status"
+link from the funded state of the existing funding wizard.
+
+## Verification
+
+- `npm run lint` — clean (no new warnings)
+- `npm run typecheck` — no errors
+- `npm test` — 110 passed (incl. 22 new)
+- `npm run build` — production build succeeds; `/escrow/[bookingRef]/timeline`
+ emitted
+
+## CI note
+
+The issue's "add caching for npm dependencies in CI" task is already satisfied:
+`.github/workflows/ci.yml` uses `actions/setup-node` with `cache: npm`, and
+runs typecheck → lint → test → build, so any PR introducing a type error is
+blocked.
diff --git a/src/app/escrow/[bookingRef]/timeline/page.tsx b/src/app/escrow/[bookingRef]/timeline/page.tsx
new file mode 100644
index 0000000..3c26773
--- /dev/null
+++ b/src/app/escrow/[bookingRef]/timeline/page.tsx
@@ -0,0 +1,30 @@
+import EscrowTimelinePanel from "@/components/escrow/EscrowTimelinePanel";
+
+interface EscrowTimelinePageProps {
+ params: Promise<{ bookingRef: string }>;
+ searchParams: Promise<{ worker?: string; fundedAt?: string }>;
+}
+
+export default async function EscrowTimelinePage({ params, searchParams }: EscrowTimelinePageProps) {
+ const { bookingRef } = await params;
+ const { worker, fundedAt } = await searchParams;
+
+ return (
+
+
+ Escrow
+
+
Escrow status timeline
+
+ Track your booking's escrow live as it moves through its on-chain lifecycle. Actions
+ appear instantly and confirm — or roll back — as the Stellar network settles them.
+
+
+
+
+ );
+}
diff --git a/src/components/escrow/EscrowTimeline.tsx b/src/components/escrow/EscrowTimeline.tsx
new file mode 100644
index 0000000..4e7293b
--- /dev/null
+++ b/src/components/escrow/EscrowTimeline.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { HiCheck } from "react-icons/hi";
+import { FaSpinner } from "react-icons/fa6";
+import Badge from "@/components/ui/Badge";
+import { useHydrated } from "./useHydrated";
+import {
+ ACTION_PENDING_LABELS,
+ STATUS_TONE,
+ type TimelineNode,
+} from "@/lib/escrowTimeline";
+
+interface EscrowTimelineProps {
+ nodes: TimelineNode[];
+}
+
+function DotConnector({ isLast }: { isLast: boolean }) {
+ return (
+
+ );
+}
+
+export default function EscrowTimeline({ nodes }: EscrowTimelineProps) {
+ const mounted = useHydrated();
+
+ return (
+
+ {nodes.map((node, index) => {
+ const isLast = index === nodes.length - 1;
+ const isOptimistic = node.phase === "optimistic";
+ const tone = STATUS_TONE[node.status];
+
+ return (
+
+
+
+ {/* Rail dot: a check for confirmed steps, a spinner for the
+ optimistic in-flight one. */}
+
+ {isOptimistic ? : }
+
+
+
+
+ {node.label}
+ {isOptimistic && (
+
+ {ACTION_PENDING_LABELS[node.action ?? "release"]}
+
+ )}
+
+
+
{node.description}
+
+
+ {isOptimistic ? (
+ Awaiting on-chain confirmation…
+ ) : (
+ {mounted ? new Date(node.at).toLocaleString() : ""}
+ )}
+ {node.txHash && (
+
+ tx {node.txHash.slice(0, 8)}…{node.txHash.slice(-6)}
+
+ )}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/escrow/EscrowTimelinePanel.tsx b/src/components/escrow/EscrowTimelinePanel.tsx
new file mode 100644
index 0000000..425e748
--- /dev/null
+++ b/src/components/escrow/EscrowTimelinePanel.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { FaCircleExclamation, FaArrowsRotate } from "react-icons/fa6";
+import Button, { type ButtonVariant } from "@/components/ui/Button";
+import Card from "@/components/ui/Card";
+import EscrowTimeline from "./EscrowTimeline";
+import { useEscrowTimeline } from "./useEscrowTimeline";
+import { useHydrated } from "./useHydrated";
+import {
+ ACTION_LABELS,
+ STATUS_DESCRIPTIONS,
+ STATUS_LABELS,
+ TERMINAL_STATUSES,
+ buildTimelineNodes,
+ type EscrowAction,
+} from "@/lib/escrowTimeline";
+
+interface EscrowTimelinePanelProps {
+ bookingRef: string;
+ workerName: string;
+ /** ISO timestamp the escrow was funded — the timeline's genesis entry. */
+ fundedAt: string;
+}
+
+/** Which button style each action gets: the value-preserving happy paths lead,
+ refund is a quiet secondary, and a dispute is a deliberate gold accent. */
+const ACTION_VARIANT: Record = {
+ release: "primary",
+ resolve: "primary",
+ cancel: "outline",
+ dispute: "gold",
+};
+
+export default function EscrowTimelinePanel({ bookingRef, workerName, fundedAt }: EscrowTimelinePanelProps) {
+ const timeline = useEscrowTimeline(bookingRef, fundedAt);
+ const { state, displayStatus, settledStatus, availableActions, isSubmitting } = timeline;
+
+ const mounted = useHydrated();
+ const announceRef = useRef(null);
+ const prevSettledRef = useRef(settledStatus);
+
+ // Announce every *confirmed* status change to screen readers — the optimistic
+ // node already carries its own aria-live, so this is specifically the "it
+ // actually landed on-chain (or changed underneath us)" signal.
+ useEffect(() => {
+ if (prevSettledRef.current !== settledStatus && announceRef.current) {
+ announceRef.current.textContent = `Escrow status is now ${STATUS_LABELS[settledStatus]}. ${STATUS_DESCRIPTIONS[settledStatus]}`;
+ }
+ prevSettledRef.current = settledStatus;
+ }, [settledStatus]);
+
+ const nodes = buildTimelineNodes(state);
+ const isTerminal = TERMINAL_STATUSES.has(settledStatus) && !isSubmitting;
+
+ return (
+
+
+
+
+
+
Escrow status
+
+ Booking {bookingRef} with {workerName}.
+
+
+
+
+
+
+
+ {state.error && (
+
+
+
+ {state.error}
+
+
+
+ Dismiss
+
+
+ Try again
+
+
+
+ )}
+
+ {availableActions.length > 0 && (
+
+
What would you like to do?
+
+ {availableActions.map((action) => (
+ timeline.submit(action)}
+ >
+ {ACTION_LABELS[action]}
+
+ ))}
+
+
+ )}
+
+ {isSubmitting && (
+
+
+ Submitting to the escrow contract — the timeline will confirm or roll back automatically.
+
+ )}
+
+ {isTerminal && (
+
+ This escrow is settled — {STATUS_LABELS[displayStatus]} . No
+ further action is needed.
+
+ )}
+
+ );
+}
+
+/** Small "live" badge: a pulsing dot plus when the status was last synced. */
+function SyncIndicator({ lastSyncedAt }: { lastSyncedAt: string | null }) {
+ return (
+
+
+
+
+
+ {lastSyncedAt ? `Live · synced ${new Date(lastSyncedAt).toLocaleTimeString()}` : "Live"}
+
+ );
+}
diff --git a/src/components/escrow/steps/FundingStatusStep.tsx b/src/components/escrow/steps/FundingStatusStep.tsx
index 7b4f9f8..9f2ab53 100644
--- a/src/components/escrow/steps/FundingStatusStep.tsx
+++ b/src/components/escrow/steps/FundingStatusStep.tsx
@@ -1,5 +1,7 @@
+import Link from "next/link";
import { FaCircleCheck, FaCircleExclamation, FaSpinner } from "react-icons/fa6";
import { formatNaira } from "@/lib/marketplace";
+import { buttonClasses } from "@/components/ui/Button";
import type { EscrowFundingContext, EscrowStateName } from "@/lib/escrowFunding";
interface FundingStatusStepProps {
@@ -33,6 +35,14 @@ export default function FundingStatusStep({ state, context }: FundingStatusStepP
{context.escrowReference}
)}
+
+ Track escrow status
+
);
}
diff --git a/src/components/escrow/useEscrowTimeline.ts b/src/components/escrow/useEscrowTimeline.ts
new file mode 100644
index 0000000..a812430
--- /dev/null
+++ b/src/components/escrow/useEscrowTimeline.ts
@@ -0,0 +1,164 @@
+"use client";
+
+import { useCallback, useEffect, useReducer, useRef, useState } from "react";
+import { fetchStatus, submitAction } from "@/lib/escrowChain";
+import {
+ actionsFor,
+ confirmedStatus,
+ effectiveStatus,
+ initialTimelineState,
+ timelineReducer,
+ type EscrowAction,
+ type TimelineState,
+} from "@/lib/escrowTimeline";
+
+/** How often to poll the chain for the authoritative status. Fast enough that
+ an externally-driven change feels live, slow enough not to hammer the RPC
+ once this points at a real endpoint. */
+const POLL_INTERVAL_MS = 4000;
+
+function newSubmissionId(): string {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
+ return `sub-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+}
+
+export interface UseEscrowTimeline {
+ state: TimelineState;
+ /** The status the UI should present (optimistic target while in flight). */
+ displayStatus: ReturnType;
+ /** The authoritative confirmed status. */
+ settledStatus: ReturnType;
+ /** Actions currently legal — empty while a submission is in flight. */
+ availableActions: EscrowAction[];
+ /** True while an optimistic action is awaiting confirmation. */
+ isSubmitting: boolean;
+ /** ISO timestamp of the last successful poll, or null before the first. */
+ lastSyncedAt: string | null;
+ submit: (action: EscrowAction) => void;
+ retry: () => void;
+ dismissError: () => void;
+}
+
+export function useEscrowTimeline(bookingRef: string, fundedAt: string): UseEscrowTimeline {
+ const [state, dispatch] = useReducer(
+ timelineReducer,
+ undefined,
+ () => initialTimelineState(fundedAt),
+ );
+ const [lastSyncedAt, setLastSyncedAt] = useState(null);
+
+ // Remember the last submitted action so `retry` can re-run it after a
+ // rolled-back failure without the component tracking it separately.
+ const lastActionRef = useRef(null);
+ // Guards against dispatching after unmount from an in-flight submission.
+ const mountedRef = useRef(true);
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const runSubmit = useCallback(
+ async (action: EscrowAction) => {
+ const submissionId = newSubmissionId();
+ lastActionRef.current = action;
+ dispatch({ type: "SUBMIT", action, submissionId, at: new Date().toISOString() });
+ try {
+ const result = await submitAction(bookingRef, action);
+ if (!mountedRef.current) return;
+ dispatch({
+ type: "CONFIRMED",
+ submissionId,
+ txHash: result.txHash ?? "",
+ at: result.at,
+ });
+ } catch (error) {
+ if (!mountedRef.current) return;
+ dispatch({
+ type: "FAILED",
+ submissionId,
+ message: error instanceof Error ? error.message : "Something went wrong. Please try again.",
+ });
+ }
+ },
+ [bookingRef],
+ );
+
+ const submit = useCallback(
+ (action: EscrowAction) => {
+ // Cheap client-side guard mirroring the reducer's own check, so an
+ // illegal action never even starts a network round trip.
+ if (!actionsFor(confirmedStatus(state)).includes(action)) return;
+ if (state.pending) return;
+ void runSubmit(action);
+ },
+ [runSubmit, state],
+ );
+
+ const retry = useCallback(() => {
+ const action = lastActionRef.current;
+ if (action) void runSubmit(action);
+ }, [runSubmit]);
+
+ const dismissError = useCallback(() => dispatch({ type: "DISMISS_ERROR" }), []);
+
+ // Poll the chain for the authoritative status. Pauses while the tab is
+ // hidden (no point polling a backgrounded tab) and polls once immediately on
+ // becoming visible again so the timeline is fresh the moment the user returns.
+ useEffect(() => {
+ let cancelled = false;
+ let timer: ReturnType | null = null;
+
+ async function poll() {
+ try {
+ const result = await fetchStatus(bookingRef);
+ if (cancelled) return;
+ setLastSyncedAt(result.at);
+ dispatch({ type: "SYNC", status: result.status, txHash: result.txHash, at: result.at });
+ } catch {
+ // A failed poll is non-fatal; the next tick tries again.
+ }
+ }
+
+ function start() {
+ if (timer) return;
+ void poll();
+ timer = setInterval(poll, POLL_INTERVAL_MS);
+ }
+
+ function stop() {
+ if (timer) {
+ clearInterval(timer);
+ timer = null;
+ }
+ }
+
+ function handleVisibility() {
+ if (document.visibilityState === "hidden") stop();
+ else start();
+ }
+
+ if (typeof document !== "undefined" && document.visibilityState === "visible") start();
+ else if (typeof document === "undefined") start();
+ document.addEventListener("visibilitychange", handleVisibility);
+
+ return () => {
+ cancelled = true;
+ stop();
+ document.removeEventListener("visibilitychange", handleVisibility);
+ };
+ }, [bookingRef]);
+
+ return {
+ state,
+ displayStatus: effectiveStatus(state),
+ settledStatus: confirmedStatus(state),
+ availableActions: state.pending ? [] : actionsFor(confirmedStatus(state)),
+ isSubmitting: state.pending !== null,
+ lastSyncedAt,
+ submit,
+ retry,
+ dismissError,
+ };
+}
diff --git a/src/components/escrow/useHydrated.ts b/src/components/escrow/useHydrated.ts
new file mode 100644
index 0000000..7f620c3
--- /dev/null
+++ b/src/components/escrow/useHydrated.ts
@@ -0,0 +1,22 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+
+const emptySubscribe = () => () => {};
+
+/**
+ * True only after hydration, false during SSR and the first client render.
+ *
+ * The timeline shows locale/timezone-dependent timestamps, which must render
+ * identically on server and first client paint or React's hydration check
+ * fails. This is the React-sanctioned way to express "client only" without a
+ * setState-in-effect: the server snapshot is `false`, the client snapshot is
+ * `true`, so the value flips exactly once, after hydration.
+ */
+export function useHydrated(): boolean {
+ return useSyncExternalStore(
+ emptySubscribe,
+ () => true,
+ () => false,
+ );
+}
diff --git a/src/lib/escrowChain.ts b/src/lib/escrowChain.ts
new file mode 100644
index 0000000..b952337
--- /dev/null
+++ b/src/lib/escrowChain.ts
@@ -0,0 +1,92 @@
+/**
+ * Simulated on-chain escrow source for the status timeline.
+ *
+ * This is the *only* impure part of the timeline feature — it stands in for the
+ * calls that will eventually hit `guildworkman-core`'s Soroban `escrow`
+ * contract (or a backend RPC in front of it). It's kept out of
+ * `escrowTimeline.ts` on purpose so the reducer there stays a pure, singleton-
+ * free function the tests can trust. See the module doc comment in
+ * `escrowTimeline.ts` (and the funding stub in `escrowFunding.ts`) for why the
+ * web app simulates rather than calls the contract today.
+ *
+ * The in-memory `ledger` behaves like the authoritative chain: `submitAction`
+ * mutates it after a realistic delay (and occasionally fails, so the rollback
+ * path is reachable), and `fetchStatus` reads it — which is what lets the
+ * polling hook observe both our own confirmed actions and, in principle, a
+ * status a counterparty advanced. Swap these two functions' bodies for real
+ * network calls and nothing else in the feature changes.
+ */
+
+import { ACTION_TARGET, type EscrowAction, type EscrowStatus } from "./escrowTimeline";
+
+export interface ChainStatus {
+ status: EscrowStatus;
+ txHash: string | null;
+ /** ISO-8601 timestamp of the observation. */
+ at: string;
+}
+
+const SUBMIT_LATENCY_MS = 1400;
+/** Roughly 1 submission in 6 fails, so the optimistic rollback path is
+ exercised in normal use without special test hooks. */
+const SUBMIT_FAILURE_RATE = 1 / 6;
+
+interface LedgerRecord {
+ status: EscrowStatus;
+ txHash: string | null;
+ at: string;
+}
+
+const ledger = new Map();
+
+function ensure(bookingRef: string): LedgerRecord {
+ let record = ledger.get(bookingRef);
+ if (!record) {
+ record = { status: "funded", txHash: null, at: new Date().toISOString() };
+ ledger.set(bookingRef, record);
+ }
+ return record;
+}
+
+function randomTxHash(): string {
+ // A Stellar tx hash is 64 hex chars; this only needs to look the part.
+ const hex = "0123456789abcdef";
+ let out = "";
+ for (let i = 0; i < 64; i += 1) out += hex[Math.floor(Math.random() * 16)];
+ return out;
+}
+
+/** Poll the authoritative escrow status. Resolves quickly — a read, not a
+ transaction. */
+export function fetchStatus(bookingRef: string): Promise {
+ const record = ensure(bookingRef);
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({ status: record.status, txHash: record.txHash, at: new Date().toISOString() });
+ }, 120);
+ });
+}
+
+/** Submit a state-changing escrow action. Resolves with the new confirmed
+ status on success, or rejects (leaving the ledger unchanged) on failure. */
+export function submitAction(bookingRef: string, action: EscrowAction): Promise {
+ const record = ensure(bookingRef);
+ return new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (Math.random() < SUBMIT_FAILURE_RATE) {
+ reject(new Error("The escrow transaction couldn't be submitted. Please try again."));
+ return;
+ }
+ record.status = ACTION_TARGET[action];
+ record.txHash = randomTxHash();
+ record.at = new Date().toISOString();
+ resolve({ status: record.status, txHash: record.txHash, at: record.at });
+ }, SUBMIT_LATENCY_MS);
+ });
+}
+
+/** Test/support hook — reset a booking's simulated ledger entry. */
+export function resetLedger(bookingRef?: string): void {
+ if (bookingRef) ledger.delete(bookingRef);
+ else ledger.clear();
+}
diff --git a/src/lib/escrowTimeline.ts b/src/lib/escrowTimeline.ts
new file mode 100644
index 0000000..6cdddd8
--- /dev/null
+++ b/src/lib/escrowTimeline.ts
@@ -0,0 +1,334 @@
+/**
+ * Real-time escrow status timeline — the on-chain escrow lifecycle modeled as
+ * an append-only event log, with optimistic updates and graceful rollback.
+ *
+ * WHY AN EVENT LOG (not a single "status" field)
+ * An escrow *timeline* is inherently a history: it's funded, then released /
+ * cancelled / disputed, and a dispute is later resolved. Rendering that as one
+ * mutable `status` throws away exactly the thing the UI needs to draw — the
+ * ordered sequence of transitions and when each happened. So the source of
+ * truth here is `history: ConfirmedEntry[]` (chronological, always rooted at
+ * "funded"), and the current status is simply its last entry. This also makes
+ * the optimistic layer clean: an in-flight action is a single `pending` node
+ * appended *after* the confirmed history, never a mutation of it, so rolling
+ * back on failure is "drop the pending node" — the confirmed history is
+ * untouched by construction.
+ *
+ * WHY OPTIMISTIC UPDATES + ROLLBACK ARE MODELED, NOT IMPROVISED
+ * The reducer below is the single place that knows how a submitted action, an
+ * on-chain confirmation, a failure, and a background poll each move the state.
+ * Every (state, event) pair is handled in one pure function the UI and tests
+ * drive directly, so "optimistically show Released, then snap back to Funded
+ * if the tx fails" isn't ad-hoc `useState` juggling scattered across a
+ * component — it's `SUBMIT` then `FAILED`, both covered by tests.
+ *
+ * WHY THIS TALKS TO A SIMULATED CHAIN (read before wiring a real endpoint)
+ * Same reason the funding wizard's `fundEscrow` is a stub (see
+ * `lib/escrowFunding.ts`): `guildworkman-core`'s Soroban `escrow` contract has
+ * `Status { Funded, Completed, Cancelled, Disputed, Resolved }` and the
+ * matching `complete` / `cancel` / `dispute` / `resolve` entrypoints, but no
+ * backend REST/RPC endpoint exposes them to the web app yet. Rather than
+ * invent a wire shape the backend can't answer, the chain functions at the
+ * bottom of this file drive an in-memory ledger that behaves like the contract
+ * (latency, an occasional failed submission, and status that other parties
+ * could advance) so the optimistic UI, rollback, and real-time reconciliation
+ * are fully exercised today. Swap those functions' bodies for real calls once
+ * the integration lands — the reducer, hook, and components don't change.
+ */
+
+/** Mirrors the Soroban `escrow` contract's `Status` enum, lower-cased for the
+ web layer. See `soroban-contracts/contracts/escrow/src/lib.rs`. */
+export type EscrowStatus = "funded" | "completed" | "cancelled" | "disputed" | "resolved";
+
+/** The state-changing entrypoints a client can invoke on the escrow contract.
+ Named for the user intent rather than the raw contract method. */
+export type EscrowAction = "release" | "cancel" | "dispute" | "resolve";
+
+/** Canonical lifecycle order, used to lay the rail out top-to-bottom. The
+ lifecycle branches after "funded" (completed | cancelled | disputed), so
+ this is a display ordering, not a linear path every escrow walks. */
+export const STATUS_ORDER: EscrowStatus[] = [
+ "funded",
+ "disputed",
+ "resolved",
+ "cancelled",
+ "completed",
+];
+
+export const STATUS_LABELS: Record = {
+ funded: "Funded",
+ completed: "Released",
+ cancelled: "Refunded",
+ disputed: "Disputed",
+ resolved: "Resolved",
+};
+
+export const STATUS_DESCRIPTIONS: Record = {
+ funded: "Payment is locked in the escrow contract on Stellar.",
+ completed: "Funds were released to the worker — the job is done.",
+ cancelled: "The booking fell through; funds were refunded to the client.",
+ disputed: "A dispute was raised. Funds stay locked pending resolution.",
+ resolved: "The dispute was resolved and the escrow settled.",
+};
+
+/** Badge tone (see `components/ui/Badge`) for each status. */
+export type StatusTone = "chain" | "success" | "gold" | "neutral";
+
+export const STATUS_TONE: Record = {
+ funded: "chain",
+ completed: "success",
+ cancelled: "neutral",
+ disputed: "gold",
+ resolved: "success",
+};
+
+/** Once reached, no further action is possible. */
+export const TERMINAL_STATUSES: ReadonlySet = new Set([
+ "completed",
+ "cancelled",
+ "resolved",
+]);
+
+export const ACTION_LABELS: Record = {
+ release: "Release funds",
+ cancel: "Cancel & refund",
+ dispute: "Raise dispute",
+ resolve: "Resolve dispute",
+};
+
+/** The status an action moves the escrow to once confirmed on-chain. */
+export const ACTION_TARGET: Record = {
+ release: "completed",
+ cancel: "cancelled",
+ dispute: "disputed",
+ resolve: "resolved",
+};
+
+/** Present-tense verb used while an action is optimistically in flight. */
+export const ACTION_PENDING_LABELS: Record = {
+ release: "Releasing funds…",
+ cancel: "Refunding…",
+ dispute: "Raising dispute…",
+ resolve: "Resolving dispute…",
+};
+
+/** The legal actions from a given confirmed status — the single source of
+ truth for which buttons the UI offers and which transitions the reducer
+ accepts. Terminal statuses return an empty list. */
+export function actionsFor(status: EscrowStatus): EscrowAction[] {
+ switch (status) {
+ case "funded":
+ return ["release", "cancel", "dispute"];
+ case "disputed":
+ return ["resolve"];
+ default:
+ return [];
+ }
+}
+
+/** A confirmed, on-chain transition. The first entry of every timeline is the
+ genesis "funded" state, which has no originating action or tx hash. */
+export interface ConfirmedEntry {
+ status: EscrowStatus;
+ action: EscrowAction | null;
+ /** ISO-8601 timestamp of when the transition was observed on-chain. */
+ at: string;
+ /** Simulated Stellar tx hash; null for the genesis "funded" entry. */
+ txHash: string | null;
+}
+
+/** An optimistic, not-yet-confirmed action shown at the end of the timeline. */
+export interface PendingUpdate {
+ action: EscrowAction;
+ targetStatus: EscrowStatus;
+ /** Local id correlating the optimistic node with its in-flight submission. */
+ submissionId: string;
+ /** ISO-8601 timestamp of when the user submitted. */
+ at: string;
+}
+
+export interface TimelineState {
+ /** Chronological confirmed history; `history[0]` is always "funded". */
+ history: ConfirmedEntry[];
+ /** The single in-flight optimistic action, or null when settled. */
+ pending: PendingUpdate | null;
+ /** Set when the last submission failed and was rolled back. */
+ error: string | null;
+}
+
+export type TimelineEvent =
+ /** User invoked an action — apply it optimistically. */
+ | { type: "SUBMIT"; action: EscrowAction; submissionId: string; at: string }
+ /** The in-flight submission confirmed on-chain. */
+ | { type: "CONFIRMED"; submissionId: string; txHash: string; at: string }
+ /** The in-flight submission failed — roll the optimistic node back. */
+ | { type: "FAILED"; submissionId: string; message: string }
+ /** A background poll observed the authoritative on-chain status. */
+ | { type: "SYNC"; status: EscrowStatus; txHash: string | null; at: string }
+ /** Dismiss a rolled-back error without submitting anything. */
+ | { type: "DISMISS_ERROR" };
+
+/** The confirmed (authoritative) status: the last entry in the history. */
+export function confirmedStatus(state: TimelineState): EscrowStatus {
+ return state.history[state.history.length - 1].status;
+}
+
+/** The status the UI should *show*: the optimistic target while an action is
+ in flight, otherwise the confirmed status. */
+export function effectiveStatus(state: TimelineState): EscrowStatus {
+ return state.pending ? state.pending.targetStatus : confirmedStatus(state);
+}
+
+export function initialTimelineState(fundedAt: string, txHash: string | null = null): TimelineState {
+ return {
+ history: [{ status: "funded", action: null, at: fundedAt, txHash }],
+ pending: null,
+ error: null,
+ };
+}
+
+function appendConfirmed(
+ state: TimelineState,
+ entry: ConfirmedEntry,
+): TimelineState {
+ return { history: [...state.history, entry], pending: null, error: null };
+}
+
+/**
+ * Pure reducer. Every branch returns a new state (or the same reference for a
+ * no-op), so callers can dispatch freely without pre-checking validity.
+ */
+export function timelineReducer(state: TimelineState, event: TimelineEvent): TimelineState {
+ switch (event.type) {
+ case "SUBMIT": {
+ // Ignore a second submission while one is already in flight, and any
+ // action that isn't legal from the current confirmed status.
+ if (state.pending) return state;
+ if (!actionsFor(confirmedStatus(state)).includes(event.action)) return state;
+ return {
+ ...state,
+ error: null,
+ pending: {
+ action: event.action,
+ targetStatus: ACTION_TARGET[event.action],
+ submissionId: event.submissionId,
+ at: event.at,
+ },
+ };
+ }
+
+ case "CONFIRMED": {
+ // Only the currently in-flight submission can confirm; a stale
+ // confirmation (already superseded by a SYNC) is ignored.
+ if (!state.pending || state.pending.submissionId !== event.submissionId) return state;
+ return appendConfirmed(state, {
+ status: state.pending.targetStatus,
+ action: state.pending.action,
+ at: event.at,
+ txHash: event.txHash,
+ });
+ }
+
+ case "FAILED": {
+ // Roll back: drop the optimistic node, keep the confirmed history, and
+ // surface the reason so the UI can offer a retry.
+ if (!state.pending || state.pending.submissionId !== event.submissionId) return state;
+ return { ...state, pending: null, error: event.message };
+ }
+
+ case "SYNC": {
+ const current = confirmedStatus(state);
+ // Already reflected — nothing to do (idempotent poll).
+ if (event.status === current) return state;
+
+ // The poll observed the exact status our in-flight action targets: the
+ // submission landed (possibly via a path other than our own CONFIRMED,
+ // e.g. the confirmation callback was lost). Commit it.
+ if (state.pending && event.status === state.pending.targetStatus) {
+ return appendConfirmed(state, {
+ status: state.pending.targetStatus,
+ action: state.pending.action,
+ at: event.at,
+ txHash: event.txHash,
+ });
+ }
+
+ // An authoritative change we didn't initiate (a counterparty acted, or a
+ // dispute was resolved elsewhere). Adopt it only if it's a legal
+ // successor of our confirmed status; drop any unrelated optimistic node,
+ // since the chain has moved on without it.
+ if (isLegalSuccessor(current, event.status)) {
+ return {
+ history: [
+ ...state.history,
+ { status: event.status, action: actionForTransition(current, event.status), at: event.at, txHash: event.txHash },
+ ],
+ pending: null,
+ // A pending action that's now impossible was effectively rolled back
+ // by the external change; note it so the UI can explain the snap-back.
+ error: state.pending ? "The escrow status changed on-chain, so your action was cancelled." : null,
+ };
+ }
+
+ // A status we can't reconcile (e.g. a non-adjacent jump) — ignore rather
+ // than corrupt the history. A real integration would refetch fully here.
+ return state;
+ }
+
+ case "DISMISS_ERROR":
+ return state.error ? { ...state, error: null } : state;
+
+ default:
+ return state;
+ }
+}
+
+/** Whether `to` is a directly reachable confirmed status from `from`. */
+export function isLegalSuccessor(from: EscrowStatus, to: EscrowStatus): boolean {
+ return actionsFor(from).some((action) => ACTION_TARGET[action] === to);
+}
+
+/** The action that produced a given transition, or null if none matches. */
+function actionForTransition(from: EscrowStatus, to: EscrowStatus): EscrowAction | null {
+ return actionsFor(from).find((action) => ACTION_TARGET[action] === to) ?? null;
+}
+
+export type TimelineNodePhase = "confirmed" | "optimistic";
+
+export interface TimelineNode {
+ status: EscrowStatus;
+ label: string;
+ description: string;
+ phase: TimelineNodePhase;
+ at: string;
+ txHash: string | null;
+ action: EscrowAction | null;
+}
+
+/** Flatten a `TimelineState` into the ordered nodes the timeline renders: the
+ confirmed history followed by the optimistic pending node (if any). */
+export function buildTimelineNodes(state: TimelineState): TimelineNode[] {
+ const nodes: TimelineNode[] = state.history.map((entry) => ({
+ status: entry.status,
+ label: STATUS_LABELS[entry.status],
+ description: STATUS_DESCRIPTIONS[entry.status],
+ phase: "confirmed",
+ at: entry.at,
+ txHash: entry.txHash,
+ action: entry.action,
+ }));
+
+ if (state.pending) {
+ nodes.push({
+ status: state.pending.targetStatus,
+ label: STATUS_LABELS[state.pending.targetStatus],
+ description: STATUS_DESCRIPTIONS[state.pending.targetStatus],
+ phase: "optimistic",
+ at: state.pending.at,
+ txHash: null,
+ action: state.pending.action,
+ });
+ }
+
+ return nodes;
+}
diff --git a/src/lib/test/escrowTimeline.test.ts b/src/lib/test/escrowTimeline.test.ts
new file mode 100644
index 0000000..2a524ba
--- /dev/null
+++ b/src/lib/test/escrowTimeline.test.ts
@@ -0,0 +1,226 @@
+import { describe, expect, it } from "vitest";
+import {
+ ACTION_TARGET,
+ actionsFor,
+ buildTimelineNodes,
+ confirmedStatus,
+ effectiveStatus,
+ initialTimelineState,
+ isLegalSuccessor,
+ timelineReducer,
+ type TimelineState,
+} from "../escrowTimeline";
+
+const T0 = "2026-08-24T10:00:00.000Z";
+const T1 = "2026-08-24T10:05:00.000Z";
+const T2 = "2026-08-24T10:06:00.000Z";
+
+function funded(): TimelineState {
+ return initialTimelineState(T0);
+}
+
+/** Drive the reducer through funded → pending(release) so tests that start
+ from an in-flight optimistic state don't repeat the setup. */
+function submittedRelease(): { state: TimelineState; submissionId: string } {
+ const submissionId = "sub-1";
+ const state = timelineReducer(funded(), {
+ type: "SUBMIT",
+ action: "release",
+ submissionId,
+ at: T1,
+ });
+ return { state, submissionId };
+}
+
+describe("metadata", () => {
+ it("lists the legal actions per status", () => {
+ expect(actionsFor("funded")).toEqual(["release", "cancel", "dispute"]);
+ expect(actionsFor("disputed")).toEqual(["resolve"]);
+ expect(actionsFor("completed")).toEqual([]);
+ expect(actionsFor("cancelled")).toEqual([]);
+ expect(actionsFor("resolved")).toEqual([]);
+ });
+
+ it("maps each action to its resulting status", () => {
+ expect(ACTION_TARGET.release).toBe("completed");
+ expect(ACTION_TARGET.cancel).toBe("cancelled");
+ expect(ACTION_TARGET.dispute).toBe("disputed");
+ expect(ACTION_TARGET.resolve).toBe("resolved");
+ });
+
+ it("knows which transitions are legal successors", () => {
+ expect(isLegalSuccessor("funded", "completed")).toBe(true);
+ expect(isLegalSuccessor("funded", "disputed")).toBe(true);
+ expect(isLegalSuccessor("disputed", "resolved")).toBe(true);
+ expect(isLegalSuccessor("funded", "resolved")).toBe(false);
+ expect(isLegalSuccessor("completed", "cancelled")).toBe(false);
+ });
+});
+
+describe("initial state", () => {
+ it("starts funded with a single history entry and no pending action", () => {
+ const state = funded();
+ expect(confirmedStatus(state)).toBe("funded");
+ expect(effectiveStatus(state)).toBe("funded");
+ expect(state.history).toHaveLength(1);
+ expect(state.history[0]).toMatchObject({ status: "funded", action: null, txHash: null });
+ expect(state.pending).toBeNull();
+ expect(state.error).toBeNull();
+ });
+});
+
+describe("SUBMIT (optimistic apply)", () => {
+ it("applies a legal action optimistically without touching confirmed history", () => {
+ const { state } = submittedRelease();
+ expect(state.pending).toMatchObject({ action: "release", targetStatus: "completed" });
+ expect(effectiveStatus(state)).toBe("completed"); // shown optimistically
+ expect(confirmedStatus(state)).toBe("funded"); // but not yet confirmed
+ expect(state.history).toHaveLength(1);
+ expect(state.error).toBeNull();
+ });
+
+ it("ignores an action that isn't legal from the current status", () => {
+ const state = funded();
+ const next = timelineReducer(state, { type: "SUBMIT", action: "resolve", submissionId: "x", at: T1 });
+ expect(next).toBe(state);
+ });
+
+ it("ignores a second submission while one is in flight", () => {
+ const { state } = submittedRelease();
+ const next = timelineReducer(state, { type: "SUBMIT", action: "cancel", submissionId: "sub-2", at: T2 });
+ expect(next).toBe(state);
+ });
+});
+
+describe("CONFIRMED", () => {
+ it("commits the in-flight action to history and clears pending", () => {
+ const { state, submissionId } = submittedRelease();
+ const next = timelineReducer(state, { type: "CONFIRMED", submissionId, txHash: "abc123", at: T2 });
+
+ expect(next.pending).toBeNull();
+ expect(confirmedStatus(next)).toBe("completed");
+ expect(next.history).toHaveLength(2);
+ expect(next.history[1]).toMatchObject({ status: "completed", action: "release", txHash: "abc123", at: T2 });
+ });
+
+ it("ignores a confirmation whose submissionId doesn't match the in-flight one", () => {
+ const { state } = submittedRelease();
+ const next = timelineReducer(state, { type: "CONFIRMED", submissionId: "stale", txHash: "z", at: T2 });
+ expect(next).toBe(state);
+ });
+});
+
+describe("FAILED (graceful rollback)", () => {
+ it("drops the optimistic node, keeps confirmed history, and surfaces the error", () => {
+ const { state, submissionId } = submittedRelease();
+ const next = timelineReducer(state, { type: "FAILED", submissionId, message: "network down" });
+
+ expect(next.pending).toBeNull();
+ expect(confirmedStatus(next)).toBe("funded"); // rolled back
+ expect(effectiveStatus(next)).toBe("funded");
+ expect(next.history).toHaveLength(1);
+ expect(next.error).toBe("network down");
+ });
+
+ it("ignores a failure for a stale submission", () => {
+ const { state } = submittedRelease();
+ const next = timelineReducer(state, { type: "FAILED", submissionId: "stale", message: "x" });
+ expect(next).toBe(state);
+ });
+});
+
+describe("SYNC (real-time reconciliation)", () => {
+ it("is a no-op when the polled status already matches", () => {
+ const state = funded();
+ const next = timelineReducer(state, { type: "SYNC", status: "funded", txHash: null, at: T1 });
+ expect(next).toBe(state);
+ });
+
+ it("adopts an authoritative external change we didn't initiate", () => {
+ const state = funded();
+ const next = timelineReducer(state, { type: "SYNC", status: "disputed", txHash: "tx1", at: T1 });
+
+ expect(confirmedStatus(next)).toBe("disputed");
+ expect(next.history).toHaveLength(2);
+ expect(next.history[1]).toMatchObject({ status: "disputed", action: "dispute", txHash: "tx1" });
+ expect(next.pending).toBeNull();
+ });
+
+ it("confirms an in-flight action when the poll observes its target status", () => {
+ const { state } = submittedRelease();
+ const next = timelineReducer(state, { type: "SYNC", status: "completed", txHash: "tx2", at: T2 });
+
+ expect(confirmedStatus(next)).toBe("completed");
+ expect(next.history).toHaveLength(2);
+ expect(next.history[1]).toMatchObject({ status: "completed", action: "release", txHash: "tx2" });
+ expect(next.pending).toBeNull();
+ expect(next.error).toBeNull();
+ });
+
+ it("lets an external change win over an unrelated in-flight action and notes the snap-back", () => {
+ const { state } = submittedRelease(); // optimistically heading to "completed"
+ const next = timelineReducer(state, { type: "SYNC", status: "disputed", txHash: "tx3", at: T2 });
+
+ expect(confirmedStatus(next)).toBe("disputed");
+ expect(next.pending).toBeNull();
+ expect(next.error).toMatch(/changed on-chain/i);
+ });
+
+ it("ignores a status it can't reconcile as a legal successor", () => {
+ const state = funded();
+ const next = timelineReducer(state, { type: "SYNC", status: "resolved", txHash: "tx", at: T1 });
+ expect(next).toBe(state);
+ });
+});
+
+describe("DISMISS_ERROR", () => {
+ it("clears a rolled-back error", () => {
+ const { state, submissionId } = submittedRelease();
+ const failed = timelineReducer(state, { type: "FAILED", submissionId, message: "boom" });
+ const cleared = timelineReducer(failed, { type: "DISMISS_ERROR" });
+ expect(cleared.error).toBeNull();
+ });
+
+ it("is a no-op when there's no error", () => {
+ const state = funded();
+ expect(timelineReducer(state, { type: "DISMISS_ERROR" })).toBe(state);
+ });
+});
+
+describe("buildTimelineNodes", () => {
+ it("renders one node per confirmed entry", () => {
+ const nodes = buildTimelineNodes(funded());
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toMatchObject({ status: "funded", phase: "confirmed" });
+ });
+
+ it("appends an optimistic node while an action is in flight", () => {
+ const { state } = submittedRelease();
+ const nodes = buildTimelineNodes(state);
+ expect(nodes).toHaveLength(2);
+ expect(nodes[0].phase).toBe("confirmed");
+ expect(nodes[1]).toMatchObject({ status: "completed", phase: "optimistic", action: "release", txHash: null });
+ });
+});
+
+describe("full lifecycle paths", () => {
+ it("walks funded → release → completed (terminal)", () => {
+ let state = funded();
+ state = timelineReducer(state, { type: "SUBMIT", action: "release", submissionId: "s", at: T1 });
+ state = timelineReducer(state, { type: "CONFIRMED", submissionId: "s", txHash: "h", at: T2 });
+ expect(confirmedStatus(state)).toBe("completed");
+ expect(actionsFor(confirmedStatus(state))).toEqual([]);
+ });
+
+ it("walks funded → dispute → resolve", () => {
+ let state = funded();
+ state = timelineReducer(state, { type: "SUBMIT", action: "dispute", submissionId: "s1", at: T1 });
+ state = timelineReducer(state, { type: "CONFIRMED", submissionId: "s1", txHash: "h1", at: T1 });
+ expect(confirmedStatus(state)).toBe("disputed");
+
+ state = timelineReducer(state, { type: "SUBMIT", action: "resolve", submissionId: "s2", at: T2 });
+ state = timelineReducer(state, { type: "CONFIRMED", submissionId: "s2", txHash: "h2", at: T2 });
+ expect(confirmedStatus(state)).toBe("resolved");
+ expect(state.history.map((h) => h.status)).toEqual(["funded", "disputed", "resolved"]);
+ });
+});