💼
diff --git a/shared/optimistic/escrow-state.test.ts b/shared/optimistic/escrow-state.test.ts
new file mode 100644
index 0000000..c03c328
--- /dev/null
+++ b/shared/optimistic/escrow-state.test.ts
@@ -0,0 +1,192 @@
+/**
+ * Tests for the optimistic escrow state store.
+ */
+import {
+ applyOptimisticUpdate,
+ confirmUpdate,
+ rollbackUpdate,
+ getOptimisticState,
+ getEffectiveStatus,
+ getPendingTransitions,
+ clearAll,
+ subscribe,
+ getSnapshot,
+ getServerSnapshot,
+ onRollback,
+} from './escrow-state'
+import type { MilestoneKey } from './types'
+import type { RollbackEvent } from './types'
+
+beforeEach(() => {
+ clearAll()
+})
+
+const key: MilestoneKey = { gigId: 'abc123', milestoneIndex: 0 }
+const key2: MilestoneKey = { gigId: 'abc123', milestoneIndex: 1 }
+
+describe('applyOptimisticUpdate', () => {
+ it('applies a valid transition and stores optimistic state', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+
+ const state = getOptimisticState(key)
+ expect(state).not.toBeNull()
+ expect(state!.status).toBe('Funded')
+ expect(state!.previousStatus).toBe('Pending')
+ expect(state!.isOptimistic).toBe(true)
+ expect(state!.transactionId).toBeTruthy()
+ })
+
+ it('throws on an invalid transition', () => {
+ expect(() => applyOptimisticUpdate(key, 'Pending', 'Released')).toThrow(
+ /Invalid escrow transition/,
+ )
+ })
+
+ it('throws when transitioning from a terminal state', () => {
+ expect(() => applyOptimisticUpdate(key, 'Released', 'Funded')).toThrow(
+ /Invalid escrow transition/,
+ )
+ })
+
+ it('uses a provided transactionId', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded', 'tx-custom-id')
+
+ const state = getOptimisticState(key)
+ expect(state!.transactionId).toBe('tx-custom-id')
+ })
+
+ it('increments the snapshot version', () => {
+ const v0 = getSnapshot().version
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ expect(getSnapshot().version).toBeGreaterThan(v0)
+ })
+})
+
+describe('confirmUpdate', () => {
+ it('clears the optimistic flag but keeps the entry', () => {
+ applyOptimisticUpdate(key, 'Funded', 'Released')
+ confirmUpdate(key)
+
+ const state = getOptimisticState(key)
+ expect(state).not.toBeNull()
+ expect(state!.isOptimistic).toBe(false)
+ expect(state!.status).toBe('Released')
+ })
+
+ it('does nothing if the key does not exist', () => {
+ // Should not throw
+ confirmUpdate({ gigId: 'nonexistent', milestoneIndex: 99 })
+ })
+})
+
+describe('rollbackUpdate', () => {
+ it('removes the entry from the store', () => {
+ applyOptimisticUpdate(key, 'Funded', 'Released')
+ rollbackUpdate(key, 'test failure')
+
+ const state = getOptimisticState(key)
+ expect(state).toBeNull()
+ })
+
+ it('emits a RollbackEvent to registered listeners', () => {
+ const events: RollbackEvent[] = []
+ const unsub = onRollback((event) => events.push(event))
+
+ applyOptimisticUpdate(key, 'Funded', 'Released')
+ rollbackUpdate(key, 'tx rejected')
+
+ expect(events).toHaveLength(1)
+ expect(events[0].previousStatus).toBe('Funded')
+ expect(events[0].attemptedStatus).toBe('Released')
+ expect(events[0].reason).toBe('tx rejected')
+ expect(events[0].key).toEqual(key)
+
+ unsub()
+ })
+
+ it('does nothing if the key does not exist', () => {
+ // Should not throw
+ rollbackUpdate({ gigId: 'nonexistent', milestoneIndex: 99 }, 'noop')
+ })
+})
+
+describe('getEffectiveStatus', () => {
+ it('returns on-chain status when no optimistic update exists', () => {
+ expect(getEffectiveStatus(key, 'Pending')).toBe('Pending')
+ })
+
+ it('returns optimistic status when an update exists', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ expect(getEffectiveStatus(key, 'Pending')).toBe('Funded')
+ })
+})
+
+describe('getPendingTransitions', () => {
+ it('returns only in-flight optimistic entries', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ applyOptimisticUpdate(key2, 'Funded', 'Released')
+ confirmUpdate(key)
+
+ const pending = getPendingTransitions()
+ expect(pending).toHaveLength(1)
+ expect(pending[0].state.status).toBe('Released')
+ })
+})
+
+describe('subscribe / getSnapshot', () => {
+ it('notifies listeners on mutations', () => {
+ const listener = jest.fn()
+ const unsub = subscribe(listener)
+
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ expect(listener).toHaveBeenCalledTimes(1)
+
+ confirmUpdate(key)
+ expect(listener).toHaveBeenCalledTimes(2)
+
+ unsub()
+ })
+
+ it('unsubscribed listener is not called', () => {
+ const listener = jest.fn()
+ const unsub = subscribe(listener)
+ unsub()
+
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ expect(listener).not.toHaveBeenCalled()
+ })
+})
+
+describe('getServerSnapshot', () => {
+ it('returns an empty snapshot', () => {
+ const snap = getServerSnapshot()
+ expect(snap.version).toBe(0)
+ expect(snap.entries.size).toBe(0)
+ })
+})
+
+describe('multiple concurrent transitions', () => {
+ it('tracks independent milestones separately', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ applyOptimisticUpdate(key2, 'Funded', 'Released')
+
+ expect(getOptimisticState(key)!.status).toBe('Funded')
+ expect(getOptimisticState(key2)!.status).toBe('Released')
+
+ rollbackUpdate(key, 'failed')
+ expect(getOptimisticState(key)).toBeNull()
+ expect(getOptimisticState(key2)!.status).toBe('Released')
+ })
+})
+
+describe('clearAll', () => {
+ it('empties the store', () => {
+ applyOptimisticUpdate(key, 'Pending', 'Funded')
+ applyOptimisticUpdate(key2, 'Funded', 'Released')
+ clearAll()
+
+ expect(getOptimisticState(key)).toBeNull()
+ expect(getOptimisticState(key2)).toBeNull()
+ expect(getPendingTransitions()).toHaveLength(0)
+ })
+})
diff --git a/shared/optimistic/escrow-state.ts b/shared/optimistic/escrow-state.ts
new file mode 100644
index 0000000..f41e413
--- /dev/null
+++ b/shared/optimistic/escrow-state.ts
@@ -0,0 +1,198 @@
+/**
+ * Optimistic escrow state store.
+ *
+ * Framework-agnostic, singleton-pattern store that tracks in-flight
+ * milestone state transitions. Designed for React's `useSyncExternalStore`
+ * but usable from any subscriber model.
+ *
+ * Follows the same module-level cache/pub-sub pattern as
+ * shared/contract-events/store.ts.
+ */
+import type { MilestoneStatus } from '../contracts-gen/escrow'
+import type {
+ MilestoneKey,
+ OptimisticMilestoneState,
+ RollbackEvent,
+ RollbackListener,
+} from './types'
+import { milestoneKeyToString, VALID_TRANSITIONS } from './types'
+
+// ── Internal State ─────────────────────────────────────────────
+
+/** In-memory map of milestone key → optimistic state. */
+const store = new Map
()
+
+/** Monotonically-increasing version counter; changes on every mutation so React can re-render. */
+let version = 0
+
+/** Subscribed React listeners (from useSyncExternalStore). */
+const listeners = new Set<() => void>()
+
+/** Rollback event subscribers (for toast notifications, analytics, etc.). */
+const rollbackListeners = new Set()
+
+// ── Snapshot ───────────────────────────────────────────────────
+
+export interface OptimisticEscrowSnapshot {
+ /** Monotonic version — changes every time the store is mutated. */
+ version: number
+ /** All currently tracked optimistic states, keyed by `gigId:milestoneIndex`. */
+ entries: ReadonlyMap
+}
+
+function buildSnapshot(): OptimisticEscrowSnapshot {
+ return { version, entries: store }
+}
+
+let cachedSnapshot: OptimisticEscrowSnapshot = buildSnapshot()
+
+function invalidate(): void {
+ version += 1
+ cachedSnapshot = buildSnapshot()
+ for (const listener of listeners) listener()
+}
+
+// ── Public API — useSyncExternalStore ──────────────────────────
+
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => { listeners.delete(listener) }
+}
+
+export function getSnapshot(): OptimisticEscrowSnapshot {
+ return cachedSnapshot
+}
+
+export function getServerSnapshot(): OptimisticEscrowSnapshot {
+ return { version: 0, entries: new Map() }
+}
+
+// ── Public API — Rollback Listeners ────────────────────────────
+
+export function onRollback(listener: RollbackListener): () => void {
+ rollbackListeners.add(listener)
+ return () => { rollbackListeners.delete(listener) }
+}
+
+// ── Public API — Mutations ─────────────────────────────────────
+
+/**
+ * Immediately applies an optimistic status change for a milestone.
+ * Validates against the legal transition table and throws if invalid.
+ *
+ * @returns The transactionId assigned to this optimistic update.
+ */
+export function applyOptimisticUpdate(
+ key: MilestoneKey,
+ currentStatus: MilestoneStatus,
+ nextStatus: MilestoneStatus,
+ transactionId?: string,
+): string {
+ const validNext = VALID_TRANSITIONS[currentStatus]
+ if (!validNext.includes(nextStatus)) {
+ throw new Error(
+ `Invalid escrow transition: ${currentStatus} → ${nextStatus}. ` +
+ `Valid transitions from ${currentStatus}: ${validNext.join(', ') || 'none'}`
+ )
+ }
+
+ const txId = transactionId ?? `opt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
+ const storeKey = milestoneKeyToString(key)
+
+ store.set(storeKey, {
+ status: nextStatus,
+ previousStatus: currentStatus,
+ isOptimistic: true,
+ transactionId: txId,
+ appliedAt: Date.now(),
+ })
+
+ invalidate()
+ return txId
+}
+
+/**
+ * Confirms an optimistic update after the on-chain transaction succeeds.
+ * Removes the optimistic flag so the UI no longer shows a pending indicator.
+ */
+export function confirmUpdate(key: MilestoneKey): void {
+ const storeKey = milestoneKeyToString(key)
+ const entry = store.get(storeKey)
+ if (!entry) return
+
+ store.set(storeKey, {
+ ...entry,
+ isOptimistic: false,
+ })
+
+ invalidate()
+}
+
+/**
+ * Rolls back an optimistic update, restoring the previous status.
+ * Emits a RollbackEvent to all registered rollback listeners.
+ */
+export function rollbackUpdate(key: MilestoneKey, reason: string): void {
+ const storeKey = milestoneKeyToString(key)
+ const entry = store.get(storeKey)
+ if (!entry) return
+
+ const rollbackEvent: RollbackEvent = {
+ key,
+ previousStatus: entry.previousStatus,
+ attemptedStatus: entry.status,
+ reason,
+ rolledBackAt: Date.now(),
+ }
+
+ store.delete(storeKey)
+ invalidate()
+
+ for (const listener of rollbackListeners) {
+ try { listener(rollbackEvent) } catch { /* swallow subscriber errors */ }
+ }
+}
+
+/**
+ * Returns the optimistic state for a specific milestone, or `null` if
+ * no optimistic update is in flight.
+ */
+export function getOptimisticState(key: MilestoneKey): OptimisticMilestoneState | null {
+ return store.get(milestoneKeyToString(key)) ?? null
+}
+
+/**
+ * Returns the effective status for a milestone — the optimistic status
+ * if one exists, otherwise the provided on-chain status.
+ */
+export function getEffectiveStatus(
+ key: MilestoneKey,
+ onChainStatus: MilestoneStatus,
+): MilestoneStatus {
+ const optimistic = store.get(milestoneKeyToString(key))
+ return optimistic ? optimistic.status : onChainStatus
+}
+
+/**
+ * Returns all currently pending (in-flight) optimistic entries.
+ */
+export function getPendingTransitions(): ReadonlyArray<{
+ key: string
+ state: OptimisticMilestoneState
+}> {
+ const pending: { key: string; state: OptimisticMilestoneState }[] = []
+ for (const [key, state] of store) {
+ if (state.isOptimistic) {
+ pending.push({ key, state })
+ }
+ }
+ return pending
+}
+
+/**
+ * Clears all optimistic state. Useful for testing or hard resets.
+ */
+export function clearAll(): void {
+ store.clear()
+ invalidate()
+}
diff --git a/shared/optimistic/types.ts b/shared/optimistic/types.ts
new file mode 100644
index 0000000..a592a85
--- /dev/null
+++ b/shared/optimistic/types.ts
@@ -0,0 +1,86 @@
+/**
+ * Types for the optimistic escrow state layer.
+ *
+ * The MilestoneStatus type is imported from the generated contract bindings
+ * so that the optimistic layer stays in sync with the on-chain schema.
+ */
+import type { MilestoneStatus } from '../contracts-gen/escrow'
+
+// ── Legal State Transitions ────────────────────────────────────
+
+/**
+ * All valid escrow state transitions that the optimistic layer will accept.
+ * Any transition not represented here will be rejected at the type level.
+ */
+export type EscrowTransition =
+ | { from: 'Pending'; to: 'Funded' }
+ | { from: 'Funded'; to: 'Released' }
+ | { from: 'Funded'; to: 'Disputed' }
+ | { from: 'Funded'; to: 'Refunded' }
+ | { from: 'Disputed'; to: 'Released' }
+ | { from: 'Disputed'; to: 'Refunded' }
+
+/**
+ * A lookup table of valid next-statuses for each current status.
+ */
+export const VALID_TRANSITIONS: Record = {
+ Pending: ['Funded'],
+ Funded: ['Released', 'Disputed', 'Refunded'],
+ Completed: [],
+ Released: [],
+ Disputed: ['Released', 'Refunded'],
+ Refunded: [],
+}
+
+// ── Optimistic State ───────────────────────────────────────────
+
+/**
+ * Composite key used to track a single milestone in the optimistic store.
+ * `gigId` is hex-encoded to avoid Buffer identity issues as map keys.
+ */
+export interface MilestoneKey {
+ gigId: string
+ milestoneIndex: number
+}
+
+/** Builds a deterministic string key from a MilestoneKey. */
+export function milestoneKeyToString(key: MilestoneKey): string {
+ return `${key.gigId}:${key.milestoneIndex}`
+}
+
+/**
+ * The shape of an optimistic entry in the in-memory store.
+ */
+export interface OptimisticMilestoneState {
+ /** Current status shown in the UI (the optimistic one). */
+ status: MilestoneStatus
+ /** The confirmed on-chain status before the optimistic update was applied. */
+ previousStatus: MilestoneStatus
+ /** True while the transaction is in-flight and unconfirmed. */
+ isOptimistic: boolean
+ /** Opaque identifier correlating this update with the Soroban transaction. */
+ transactionId: string
+ /** Timestamp (ms) when the optimistic update was applied, for staleness checks. */
+ appliedAt: number
+}
+
+// ── Rollback Events ────────────────────────────────────────────
+
+/**
+ * Emitted when an optimistic update is rolled back due to a transaction
+ * failure, timeout, or explicit cancellation.
+ */
+export interface RollbackEvent {
+ key: MilestoneKey
+ /** The status the milestone was restored to. */
+ previousStatus: MilestoneStatus
+ /** The status we optimistically tried to transition to. */
+ attemptedStatus: MilestoneStatus
+ /** Human-readable reason for the rollback. */
+ reason: string
+ /** Timestamp (ms) of the rollback. */
+ rolledBackAt: number
+}
+
+/** Callback signature for rollback event subscribers. */
+export type RollbackListener = (event: RollbackEvent) => void