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 ( +
  1. + + + {/* 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… + ) : ( + + )} + {node.txHash && ( + + tx {node.txHash.slice(0, 8)}…{node.txHash.slice(-6)} + + )} +
    +
    +
  2. + ); + })} +
+ ); +} 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} +

+
+ + +
+
+ )} + + {availableActions.length > 0 && ( +
+

What would you like to do?

+
+ {availableActions.map((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"]); + }); +});