From 45dea4d0c2da8157cb54596205e204cac02f2634 Mon Sep 17 00:00:00 2001 From: dahgold001 Date: Thu, 27 Aug 2026 11:40:14 +0100 Subject: [PATCH 1/4] feat: Implement Optimistic UI with Rollback for Escrow State Transitions (#62) --- .github/workflows/ci.yml | 8 + .../escrow-state-badge.module.css | 142 ++++++++ components/atoms/escrow-state-badge/index.tsx | 55 +++ components/atoms/index.tsx | 1 + .../escrow-milestone-tracker.module.css | 239 +++++++++++++ .../escrow-milestone-tracker/index.tsx | 235 +++++++++++++ components/molecules/index.tsx | 1 + hooks/index.ts | 1 + hooks/useOptimisticEscrow.test.ts | 171 +++++++++ hooks/useOptimisticEscrow.ts | 198 +++++++++++ package-lock.json | 331 ------------------ pages/dashboard.tsx | 141 +++++++- shared/optimistic/escrow-state.test.ts | 192 ++++++++++ shared/optimistic/escrow-state.ts | 198 +++++++++++ shared/optimistic/types.ts | 86 +++++ 15 files changed, 1665 insertions(+), 334 deletions(-) create mode 100644 components/atoms/escrow-state-badge/escrow-state-badge.module.css create mode 100644 components/atoms/escrow-state-badge/index.tsx create mode 100644 components/molecules/escrow-milestone-tracker/escrow-milestone-tracker.module.css create mode 100644 components/molecules/escrow-milestone-tracker/index.tsx create mode 100644 hooks/useOptimisticEscrow.test.ts create mode 100644 hooks/useOptimisticEscrow.ts create mode 100644 shared/optimistic/escrow-state.test.ts create mode 100644 shared/optimistic/escrow-state.ts create mode 100644 shared/optimistic/types.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5f4320..18d87bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,14 @@ jobs: node-version: ${{ matrix.node-version }} cache: npm + - name: Cache npm dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}-node-${{ matrix.node-version }}- + - name: Install dependencies run: npm ci diff --git a/components/atoms/escrow-state-badge/escrow-state-badge.module.css b/components/atoms/escrow-state-badge/escrow-state-badge.module.css new file mode 100644 index 0000000..de8f050 --- /dev/null +++ b/components/atoms/escrow-state-badge/escrow-state-badge.module.css @@ -0,0 +1,142 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.025em; + text-transform: uppercase; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +/* ── Status Colors ──────────────────────────────────────────── */ + +.Pending { + background-color: rgb(243 244 246); + color: rgb(75 85 99); +} + +.Funded { + background-color: rgb(219 234 254); + color: rgb(29 78 216); +} + +.Completed { + background-color: rgb(220 252 231); + color: rgb(21 128 61); +} + +.Released { + background-color: rgb(187 247 208); + color: rgb(22 101 52); +} + +.Disputed { + background-color: rgb(254 226 226); + color: rgb(185 28 28); +} + +.Refunded { + background-color: rgb(254 243 199); + color: rgb(146 64 14); +} + +/* ── Dark Mode ──────────────────────────────────────────────── */ + +:global(.dark) .Pending { + background-color: rgb(31 41 55); + color: rgb(156 163 175); +} + +:global(.dark) .Funded { + background-color: rgb(30 58 138 / 0.3); + color: rgb(147 197 253); +} + +:global(.dark) .Completed { + background-color: rgb(20 83 45 / 0.3); + color: rgb(134 239 172); +} + +:global(.dark) .Released { + background-color: rgb(20 83 45 / 0.3); + color: rgb(74 222 128); +} + +:global(.dark) .Disputed { + background-color: rgb(127 29 29 / 0.3); + color: rgb(252 165 165); +} + +:global(.dark) .Refunded { + background-color: rgb(120 53 15 / 0.3); + color: rgb(253 224 71); +} + +/* ── Optimistic Pulse ───────────────────────────────────────── */ + +.optimistic { + animation: optimisticPulse 1.5s ease-in-out infinite; +} + +@keyframes optimisticPulse { + 0%, 100% { + opacity: 1; + box-shadow: 0 0 0 0 currentColor; + } + 50% { + opacity: 0.75; + box-shadow: 0 0 0 4px transparent; + } +} + +/* ── Pulse Dot ──────────────────────────────────────────────── */ + +.pulseDot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: currentColor; + animation: dotPulse 1.5s ease-in-out infinite; +} + +@keyframes dotPulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.4; + transform: scale(1.4); + } +} + +/* ── Rollback Flash ─────────────────────────────────────────── */ + +.rollbackFlash { + animation: rollbackShake 0.5s ease-in-out; +} + +@keyframes rollbackShake { + 0%, 100% { + transform: translateX(0); + background-color: rgb(254 226 226); + color: rgb(185 28 28); + } + 20% { + transform: translateX(-3px); + } + 40% { + transform: translateX(3px); + } + 60% { + transform: translateX(-2px); + } + 80% { + transform: translateX(2px); + } +} diff --git a/components/atoms/escrow-state-badge/index.tsx b/components/atoms/escrow-state-badge/index.tsx new file mode 100644 index 0000000..4f11303 --- /dev/null +++ b/components/atoms/escrow-state-badge/index.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react' +import type { MilestoneStatus } from '../../../shared/contracts-gen/escrow' +import styles from './escrow-state-badge.module.css' + +export interface EscrowStateBadgeProps { + /** The current (or optimistic) milestone status. */ + status: MilestoneStatus + /** Whether this status is an unconfirmed optimistic update. */ + isOptimistic?: boolean + /** If true, play the rollback flash animation (automatically clears after animation ends). */ + showRollback?: boolean + /** Optional extra CSS class names. */ + className?: string +} + +/** + * Displays the current escrow milestone status as a color-coded badge. + * + * - Pulses when `isOptimistic` is true (pending on-chain confirmation) + * - Flashes/shakes when `showRollback` fires (transaction was rejected) + */ +export function EscrowStateBadge({ + status, + isOptimistic = false, + showRollback = false, + className, +}: EscrowStateBadgeProps) { + const [flashing, setFlashing] = useState(false) + + useEffect(() => { + if (showRollback) { + setFlashing(true) + const timer = setTimeout(() => setFlashing(false), 600) + return () => clearTimeout(timer) + } + }, [showRollback]) + + const classes = [ + styles.badge, + styles[status], + isOptimistic ? styles.optimistic : '', + flashing ? styles.rollbackFlash : '', + className ?? '', + ] + .filter(Boolean) + .join(' ') + + return ( + + {isOptimistic && + ) +} diff --git a/components/atoms/index.tsx b/components/atoms/index.tsx index 554cbbb..9fd4480 100644 --- a/components/atoms/index.tsx +++ b/components/atoms/index.tsx @@ -11,3 +11,4 @@ export * from './theme-toggle' export * from './spacer' export * from './markdown-renderer' export * from './error-boundary' +export * from './escrow-state-badge' diff --git a/components/molecules/escrow-milestone-tracker/escrow-milestone-tracker.module.css b/components/molecules/escrow-milestone-tracker/escrow-milestone-tracker.module.css new file mode 100644 index 0000000..c451b92 --- /dev/null +++ b/components/molecules/escrow-milestone-tracker/escrow-milestone-tracker.module.css @@ -0,0 +1,239 @@ +.tracker { + display: flex; + flex-direction: column; + gap: 0; +} + +/* ── Milestone Item ─────────────────────────────────────────── */ + +.milestone { + display: flex; + align-items: flex-start; + gap: 16px; + position: relative; + padding: 16px 0; +} + +.milestone:not(:last-child)::before { + content: ''; + position: absolute; + left: 15px; + top: 48px; + bottom: 0; + width: 2px; + background: rgb(229 231 235); +} + +:global(.dark) .milestone:not(:last-child)::before { + background: rgb(55 65 81); +} + +/* ── Step Circle ────────────────────────────────────────────── */ + +.stepCircle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 0.75rem; + font-weight: 700; + border: 2px solid rgb(209 213 219); + background: white; + color: rgb(107 114 128); + z-index: 1; + transition: all 0.3s ease; +} + +:global(.dark) .stepCircle { + background: rgb(17 24 39); + border-color: rgb(75 85 99); + color: rgb(156 163 175); +} + +.stepCircle.funded { + border-color: rgb(59 130 246); + background: rgb(219 234 254); + color: rgb(29 78 216); +} + +.stepCircle.released { + border-color: rgb(34 197 94); + background: rgb(220 252 231); + color: rgb(22 101 52); +} + +.stepCircle.disputed { + border-color: rgb(239 68 68); + background: rgb(254 226 226); + color: rgb(185 28 28); +} + +.stepCircle.refunded { + border-color: rgb(245 158 11); + background: rgb(254 243 199); + color: rgb(146 64 14); +} + +.stepCircle.optimistic { + animation: circleGlow 1.5s ease-in-out infinite; +} + +@keyframes circleGlow { + 0%, 100% { + box-shadow: 0 0 0 0 rgb(99 102 241 / 0.4); + } + 50% { + box-shadow: 0 0 0 6px rgb(99 102 241 / 0); + } +} + +/* ── Content ────────────────────────────────────────────────── */ + +.content { + flex: 1; + min-width: 0; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.label { + font-size: 0.875rem; + font-weight: 600; + color: rgb(17 24 39); +} + +:global(.dark) .label { + color: rgb(243 244 246); +} + +.amount { + font-size: 0.75rem; + color: rgb(107 114 128); + font-variant-numeric: tabular-nums; +} + +:global(.dark) .amount { + color: rgb(156 163 175); +} + +/* ── Actions Row ────────────────────────────────────────────── */ + +.actions { + display: flex; + gap: 8px; + margin-top: 8px; + flex-wrap: wrap; +} + +.actionBtn { + padding: 4px 12px; + border-radius: 6px; + font-size: 0.75rem; + font-weight: 600; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.actionBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.fundBtn { + background: rgb(219 234 254); + color: rgb(29 78 216); + border-color: rgb(191 219 254); +} + +.fundBtn:hover:not(:disabled) { + background: rgb(191 219 254); +} + +.releaseBtn { + background: rgb(220 252 231); + color: rgb(22 101 52); + border-color: rgb(187 247 208); +} + +.releaseBtn:hover:not(:disabled) { + background: rgb(187 247 208); +} + +.disputeBtn { + background: rgb(254 226 226); + color: rgb(185 28 28); + border-color: rgb(254 202 202); +} + +.disputeBtn:hover:not(:disabled) { + background: rgb(254 202 202); +} + +.refundBtn { + background: rgb(254 243 199); + color: rgb(146 64 14); + border-color: rgb(253 230 138); +} + +.refundBtn:hover:not(:disabled) { + background: rgb(253 230 138); +} + +/* ── Rollback Notice ────────────────────────────────────────── */ + +.rollbackNotice { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 0.75rem; + background: rgb(254 226 226); + color: rgb(185 28 28); + animation: fadeInSlide 0.3s ease-out; +} + +:global(.dark) .rollbackNotice { + background: rgb(127 29 29 / 0.3); + color: rgb(252 165 165); +} + +@keyframes fadeInSlide { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.rollbackIcon { + flex-shrink: 0; +} + +/* ── Empty State ────────────────────────────────────────────── */ + +.emptyState { + text-align: center; + padding: 24px; + color: rgb(107 114 128); + font-size: 0.875rem; +} + +:global(.dark) .emptyState { + color: rgb(156 163 175); +} diff --git a/components/molecules/escrow-milestone-tracker/index.tsx b/components/molecules/escrow-milestone-tracker/index.tsx new file mode 100644 index 0000000..2608009 --- /dev/null +++ b/components/molecules/escrow-milestone-tracker/index.tsx @@ -0,0 +1,235 @@ +import { useCallback, useState } from 'react' +import type { MilestoneStatus } from '../../../shared/contracts-gen/escrow' +import type { RollbackEvent } from '../../../shared/optimistic/types' +import { EscrowStateBadge } from '../../atoms/escrow-state-badge' +import styles from './escrow-milestone-tracker.module.css' + +// ── Types ────────────────────────────────────────────────────── + +export interface TrackerMilestone { + /** Milestone index (0-based). */ + index: number + /** Display label, e.g. "Design mockups". */ + label: string + /** Amount in human-readable units (e.g. XLM or USDC). */ + amount: string + /** Token symbol for display. */ + token: string + /** Current confirmed on-chain status. */ + status: MilestoneStatus +} + +export interface EscrowMilestoneTrackerProps { + /** Hex-encoded gig ID. */ + gigId: string + /** Ordered list of milestones for this gig. */ + milestones: TrackerMilestone[] + /** Called to get the effective (possibly optimistic) status for a milestone. */ + getEffectiveStatus?: (milestoneIndex: number) => MilestoneStatus + /** Whether each milestone has an optimistic update pending. */ + isOptimistic?: (milestoneIndex: number) => boolean + /** Called when the user clicks "Fund" on a milestone. */ + onFund?: (milestoneIndex: number) => void + /** Called when the user clicks "Release" on a milestone. */ + onRelease?: (milestoneIndex: number) => void + /** Called when the user clicks "Dispute" on a milestone. */ + onDispute?: (milestoneIndex: number) => void + /** Called when the user clicks "Refund" on a milestone. */ + onRefund?: (milestoneIndex: number) => void + /** Most recent rollback event, used to flash the affected milestone. */ + lastRollback?: RollbackEvent | null +} + +// ── Helpers ──────────────────────────────────────────────────── + +function getStepCircleClass(status: MilestoneStatus, isOpt: boolean): string { + const classes = [styles.stepCircle] + switch (status) { + case 'Funded': + classes.push(styles.funded) + break + case 'Released': + case 'Completed': + classes.push(styles.released) + break + case 'Disputed': + classes.push(styles.disputed) + break + case 'Refunded': + classes.push(styles.refunded) + break + } + if (isOpt) classes.push(styles.optimistic) + return classes.join(' ') +} + +function canFund(status: MilestoneStatus): boolean { + return status === 'Pending' +} + +function canRelease(status: MilestoneStatus): boolean { + return status === 'Funded' || status === 'Disputed' +} + +function canDispute(status: MilestoneStatus): boolean { + return status === 'Funded' +} + +function canRefund(status: MilestoneStatus): boolean { + return status === 'Funded' || status === 'Disputed' +} + +// ── Component ────────────────────────────────────────────────── + +/** + * Renders a milestone timeline with optimistic state badges and action buttons. + * Each milestone shows its current (or optimistic) status and provides + * contextual actions based on the allowed state transitions. + */ +export function EscrowMilestoneTracker({ + gigId, + milestones, + getEffectiveStatus, + isOptimistic, + onFund, + onRelease, + onDispute, + onRefund, + lastRollback, +}: EscrowMilestoneTrackerProps) { + const [dismissedRollbacks, setDismissedRollbacks] = useState>(new Set()) + + const dismissRollback = useCallback( + (milestoneIndex: number) => { + setDismissedRollbacks((prev) => { + const next = new Set(prev) + next.add(milestoneIndex) + return next + }) + }, + [], + ) + + if (milestones.length === 0) { + return ( +
+ No milestones configured for this escrow. +
+ ) + } + + return ( +
+ {milestones.map((milestone) => { + const effectiveStatus = getEffectiveStatus + ? getEffectiveStatus(milestone.index) + : milestone.status + const isOpt = isOptimistic ? isOptimistic(milestone.index) : false + + const hasRollback = + lastRollback && + lastRollback.key.milestoneIndex === milestone.index && + lastRollback.key.gigId === gigId && + !dismissedRollbacks.has(milestone.index) + + return ( +
+ {/* Step circle */} + + + {/* Content */} +
+
+ {milestone.label} + +
+ + + {milestone.amount} {milestone.token} + + + {/* Action buttons — disabled when optimistic is in-flight */} +
+ {canFund(effectiveStatus) && onFund && ( + + )} + {canRelease(effectiveStatus) && onRelease && ( + + )} + {canDispute(effectiveStatus) && onDispute && ( + + )} + {canRefund(effectiveStatus) && onRefund && ( + + )} +
+ + {/* Rollback notice */} + {hasRollback && lastRollback && ( +
+ + + Transaction rolled back: {lastRollback.reason}. + Status restored to {lastRollback.previousStatus}. + + +
+ )} +
+
+ ) + })} +
+ ) +} diff --git a/components/molecules/index.tsx b/components/molecules/index.tsx index e60bce9..ae4df27 100644 --- a/components/molecules/index.tsx +++ b/components/molecules/index.tsx @@ -14,3 +14,4 @@ export * from './evidence-viewer' export * from './evidence-submission' export * from './juror-vote-panel' export * from './vote-tally' +export * from './escrow-milestone-tracker' diff --git a/hooks/index.ts b/hooks/index.ts index 3681fdc..331040e 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -12,3 +12,4 @@ export * from "./useUserProfile"; export * from './useDisputes'; export * from './useDispute'; export * from './useVoting'; +export * from './useOptimisticEscrow'; diff --git a/hooks/useOptimisticEscrow.test.ts b/hooks/useOptimisticEscrow.test.ts new file mode 100644 index 0000000..1594431 --- /dev/null +++ b/hooks/useOptimisticEscrow.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for the useOptimisticEscrow hook. + * + * These tests verify that the hook correctly applies optimistic updates, + * confirms them on success, and rolls back on failure. + */ +import { renderHook, act } from '@testing-library/react' +import { useOptimisticEscrow } from './useOptimisticEscrow' +import { clearAll, getOptimisticState } from '../shared/optimistic/escrow-state' +import type { MilestoneKey } from '../shared/optimistic/types' + +// ── Mock the underlying escrow contract ──────────────────────── + +const mockDeposit = jest.fn, [Buffer, number, string, bigint]>() +const mockRelease = jest.fn, [Buffer, number]>() +const mockRefund = jest.fn, [Buffer, number]>() +const mockGetBalance = jest.fn, [Buffer, number]>() +const mockGetMilestones = jest.fn() +const mockClearError = jest.fn() + +jest.mock('./useEscrowContract', () => ({ + useEscrowContract: () => ({ + deposit: mockDeposit, + release: mockRelease, + refund: mockRefund, + getBalance: mockGetBalance, + getMilestones: mockGetMilestones, + isReady: true, + error: null, + clearError: mockClearError, + }), +})) + +// ── Helpers ──────────────────────────────────────────────────── + +const TEST_GIG_ID = Buffer.from('deadbeef', 'hex') +const TEST_GIG_HEX = 'deadbeef' + +function makeKey(milestoneIndex: number): MilestoneKey { + return { gigId: TEST_GIG_HEX, milestoneIndex } +} + +// ── Tests ────────────────────────────────────────────────────── + +beforeEach(() => { + clearAll() + jest.clearAllMocks() +}) + +describe('useOptimisticEscrow', () => { + describe('deposit', () => { + it('applies optimistic update before contract call and confirms on success', async () => { + mockDeposit.mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useOptimisticEscrow()) + + await act(async () => { + await result.current.deposit(TEST_GIG_ID, 0, 'USDC', 1000n, 'Pending') + }) + + // After success, the optimistic entry should be confirmed (isOptimistic = false) + const state = getOptimisticState(makeKey(0)) + expect(state).not.toBeNull() + expect(state!.isOptimistic).toBe(false) + expect(state!.status).toBe('Funded') + }) + + it('rolls back on deposit failure', async () => { + mockDeposit.mockRejectedValueOnce(new Error('Insufficient balance')) + + const onRollbackFn = jest.fn() + const { result } = renderHook(() => useOptimisticEscrow({ onRollback: onRollbackFn })) + + await expect( + act(async () => { + await result.current.deposit(TEST_GIG_ID, 0, 'USDC', 1000n, 'Pending') + }), + ).rejects.toThrow('Insufficient balance') + + // State should be rolled back + const state = getOptimisticState(makeKey(0)) + expect(state).toBeNull() + }) + }) + + describe('release', () => { + it('applies optimistic Released status and confirms on success', async () => { + mockRelease.mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useOptimisticEscrow()) + + await act(async () => { + await result.current.release(TEST_GIG_ID, 0, 'Funded') + }) + + const state = getOptimisticState(makeKey(0)) + expect(state!.status).toBe('Released') + expect(state!.isOptimistic).toBe(false) + }) + + it('rolls back on release failure', async () => { + mockRelease.mockRejectedValueOnce(new Error('Not authorized')) + + const { result } = renderHook(() => useOptimisticEscrow()) + + await expect( + act(async () => { + await result.current.release(TEST_GIG_ID, 0, 'Funded') + }), + ).rejects.toThrow('Not authorized') + + expect(getOptimisticState(makeKey(0))).toBeNull() + }) + }) + + describe('refund', () => { + it('applies optimistic Refunded status and confirms on success', async () => { + mockRefund.mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => useOptimisticEscrow()) + + await act(async () => { + await result.current.refund(TEST_GIG_ID, 0, 'Funded') + }) + + const state = getOptimisticState(makeKey(0)) + expect(state!.status).toBe('Refunded') + expect(state!.isOptimistic).toBe(false) + }) + + it('rolls back on refund failure', async () => { + mockRefund.mockRejectedValueOnce(new Error('Transaction failed')) + + const { result } = renderHook(() => useOptimisticEscrow()) + + await expect( + act(async () => { + await result.current.refund(TEST_GIG_ID, 0, 'Funded') + }), + ).rejects.toThrow('Transaction failed') + + expect(getOptimisticState(makeKey(0))).toBeNull() + }) + }) + + describe('getStatus', () => { + it('returns on-chain status when no optimistic update exists', () => { + const { result } = renderHook(() => useOptimisticEscrow()) + expect(result.current.getStatus(TEST_GIG_ID, 0, 'Pending')).toBe('Pending') + }) + + it('returns optimistic status when an update exists', () => { + const { result } = renderHook(() => useOptimisticEscrow()) + + // Manually apply an optimistic update via the store + act(() => { + const { applyOptimisticUpdate } = require('../shared/optimistic/escrow-state') + applyOptimisticUpdate(makeKey(0), 'Pending', 'Funded') + }) + + expect(result.current.getStatus(TEST_GIG_ID, 0, 'Pending')).toBe('Funded') + }) + }) + + describe('isOptimistic', () => { + it('returns false when no optimistic update exists', () => { + const { result } = renderHook(() => useOptimisticEscrow()) + expect(result.current.isOptimistic(TEST_GIG_ID, 0)).toBe(false) + }) + }) +}) diff --git a/hooks/useOptimisticEscrow.ts b/hooks/useOptimisticEscrow.ts new file mode 100644 index 0000000..2973144 --- /dev/null +++ b/hooks/useOptimisticEscrow.ts @@ -0,0 +1,198 @@ +/** + * React hook that wraps useEscrowContract with optimistic UI updates. + * + * Provides the same deposit/release/refund API as useEscrowContract, but + * instantly reflects state changes in the UI and automatically rolls back + * if the on-chain transaction fails. + */ +import { useCallback, useEffect, useSyncExternalStore } from 'react' +import { useEscrowContract } from './useEscrowContract' +import type { MilestoneStatus } from '../shared/contracts-gen/escrow' +import type { MilestoneKey, RollbackEvent, OptimisticMilestoneState } from '../shared/optimistic/types' +import { + subscribe, + getSnapshot, + getServerSnapshot, + applyOptimisticUpdate, + confirmUpdate, + rollbackUpdate, + getOptimisticState, + getEffectiveStatus, + getPendingTransitions, + onRollback, + type OptimisticEscrowSnapshot, +} from '../shared/optimistic/escrow-state' + +export type { RollbackEvent, OptimisticMilestoneState } + +/** Encode a Buffer gigId to a stable hex string for use as a map key. */ +function gigIdToHex(gigId: Buffer): string { + return Buffer.from(gigId).toString('hex') +} + +export interface UseOptimisticEscrowOptions { + /** Called when a rollback occurs; useful for triggering toast notifications. */ + onRollback?: (event: RollbackEvent) => void +} + +export interface UseOptimisticEscrowResult { + /** Deposit funds into a milestone with optimistic update. */ + deposit: ( + gigId: Buffer, + milestoneIndex: number, + token: string, + amount: bigint, + currentStatus: MilestoneStatus, + ) => Promise + + /** Release escrowed funds with optimistic update. */ + release: ( + gigId: Buffer, + milestoneIndex: number, + currentStatus: MilestoneStatus, + ) => Promise + + /** Refund escrowed funds with optimistic update. */ + refund: ( + gigId: Buffer, + milestoneIndex: number, + currentStatus: MilestoneStatus, + ) => Promise + + /** Get the effective status for a milestone (optimistic if pending, on-chain otherwise). */ + getStatus: (gigId: Buffer, milestoneIndex: number, onChainStatus: MilestoneStatus) => MilestoneStatus + + /** Check if a specific milestone has an unconfirmed optimistic update. */ + isOptimistic: (gigId: Buffer, milestoneIndex: number) => boolean + + /** All currently in-flight optimistic transitions. */ + pendingTransitions: ReadonlyArray<{ key: string; state: OptimisticMilestoneState }> + + /** Store snapshot version for fine-grained re-render control. */ + snapshot: OptimisticEscrowSnapshot + + /** Whether the underlying escrow contract is configured and ready. */ + isReady: boolean + + /** Last error from the underlying contract call. */ + error: string | null + + /** Clear the last error. */ + clearError: () => void +} + +export function useOptimisticEscrow( + options: UseOptimisticEscrowOptions = {}, +): UseOptimisticEscrowResult { + const escrow = useEscrowContract() + + // Subscribe to the optimistic store for re-renders + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + // Register the rollback callback + useEffect(() => { + if (!options.onRollback) return + return onRollback(options.onRollback) + }, [options.onRollback]) + + const deposit = useCallback( + async ( + gigId: Buffer, + milestoneIndex: number, + token: string, + amount: bigint, + currentStatus: MilestoneStatus, + ) => { + const key: MilestoneKey = { gigId: gigIdToHex(gigId), milestoneIndex } + + // Apply optimistic update: Pending → Funded + applyOptimisticUpdate(key, currentStatus, 'Funded') + + try { + await escrow.deposit(gigId, milestoneIndex, token, amount) + confirmUpdate(key) + } catch (err) { + const reason = err instanceof Error ? err.message : 'Deposit transaction failed' + rollbackUpdate(key, reason) + throw err + } + }, + [escrow], + ) + + const release = useCallback( + async ( + gigId: Buffer, + milestoneIndex: number, + currentStatus: MilestoneStatus, + ) => { + const key: MilestoneKey = { gigId: gigIdToHex(gigId), milestoneIndex } + + // Apply optimistic update: Funded/Disputed → Released + applyOptimisticUpdate(key, currentStatus, 'Released') + + try { + await escrow.release(gigId, milestoneIndex) + confirmUpdate(key) + } catch (err) { + const reason = err instanceof Error ? err.message : 'Release transaction failed' + rollbackUpdate(key, reason) + throw err + } + }, + [escrow], + ) + + const refund = useCallback( + async ( + gigId: Buffer, + milestoneIndex: number, + currentStatus: MilestoneStatus, + ) => { + const key: MilestoneKey = { gigId: gigIdToHex(gigId), milestoneIndex } + + // Apply optimistic update: Funded/Disputed → Refunded + applyOptimisticUpdate(key, currentStatus, 'Refunded') + + try { + await escrow.refund(gigId, milestoneIndex) + confirmUpdate(key) + } catch (err) { + const reason = err instanceof Error ? err.message : 'Refund transaction failed' + rollbackUpdate(key, reason) + throw err + } + }, + [escrow], + ) + + const getStatus = useCallback( + (gigId: Buffer, milestoneIndex: number, onChainStatus: MilestoneStatus): MilestoneStatus => { + const key: MilestoneKey = { gigId: gigIdToHex(gigId), milestoneIndex } + return getEffectiveStatus(key, onChainStatus) + }, + [], + ) + + const isOptimisticFn = useCallback( + (gigId: Buffer, milestoneIndex: number): boolean => { + const key: MilestoneKey = { gigId: gigIdToHex(gigId), milestoneIndex } + const state = getOptimisticState(key) + return state?.isOptimistic ?? false + }, + [], + ) + + return { + deposit, + release, + refund, + getStatus, + isOptimistic: isOptimisticFn, + pendingTransitions: getPendingTransitions(), + snapshot, + isReady: escrow.isReady, + error: escrow.error, + clearError: escrow.clearError, + } +} diff --git a/package-lock.json b/package-lock.json index 14504f0..86af925 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1756,20 +1756,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jest/pattern": { - "version": "30.4.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -2084,19 +2070,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/@jest/schemas": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jest/source-map": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", @@ -2337,51 +2310,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/@jest/transform": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "dev": true, @@ -3112,13 +3040,6 @@ "version": "1.21.5", "license": "MIT" }, - "node_modules/@sinclair/typebox": { - "version": "0.34.52", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -4386,61 +4307,6 @@ "node": ">= 0.4" } }, - "node_modules/babel-jest": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/transform": "30.4.1", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/babel-preset-current-node-syntax": { "version": "1.2.0", "dev": true, @@ -4466,23 +4332,6 @@ "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/babel-preset-jest": { - "version": "30.4.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, "node_modules/bail": { "version": "2.0.2", "license": "MIT", @@ -4913,22 +4762,6 @@ "node": ">= 6" } }, - "node_modules/ci-info": { - "version": "4.4.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/client-only": { "version": "0.0.1", "license": "MIT" @@ -9067,60 +8900,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-haste-map": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-haste-map/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "4.0.5", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-leak-detector": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", @@ -9460,16 +9239,6 @@ } } }, - "node_modules/jest-regex-util": { - "version": "30.4.0", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "node_modules/jest-resolve": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", @@ -10670,37 +10439,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/jest-util": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.5", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -10897,39 +10635,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker": { - "version": "30.4.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jest/node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -12489,15 +12194,6 @@ "node": ">=10" } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [ @@ -16464,33 +16160,6 @@ "dev": true, "license": "ISC" }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx index 5e17620..e9b3200 100644 --- a/pages/dashboard.tsx +++ b/pages/dashboard.tsx @@ -1,11 +1,21 @@ -import { useState } from 'react' +import { useCallback, useState } from 'react' import type { NextPage } from 'next' import Head from 'next/head' import Link from 'next/link' import { Navbar } from '../components/organisms' -import { USDCConverter, FileUpload, DeliverableViewer, DashboardSidebar } from '../components/molecules' -import type { UploadedFile, DeliverableFile } from '../components/molecules' +import { USDCConverter, FileUpload, DeliverableViewer, DashboardSidebar, EscrowMilestoneTracker } from '../components/molecules' +import type { UploadedFile, DeliverableFile, TrackerMilestone } from '../components/molecules' import { useUSDCPrice, formatUSD, convertToUSD } from '../hooks/useUSDCPrice' +import type { MilestoneStatus } from '../shared/contracts-gen/escrow' +import type { RollbackEvent } from '../shared/optimistic/types' +import { + applyOptimisticUpdate, + confirmUpdate, + rollbackUpdate, + getOptimisticState, + getEffectiveStatus, +} from '../shared/optimistic/escrow-state' +import type { MilestoneKey } from '../shared/optimistic/types' interface NavItem { label: string @@ -53,9 +63,22 @@ const SAMPLE_DELIVERABLES: DeliverableFile[] = [ }, ] +// ── Demo Milestones ──────────────────────────────────────────── + +const DEMO_GIG_ID = '00000000deadbeef' + +const INITIAL_MILESTONES: TrackerMilestone[] = [ + { index: 0, label: 'Design Mockups', amount: '500', token: 'USDC', status: 'Funded' }, + { index: 1, label: 'Frontend Implementation', amount: '1200', token: 'USDC', status: 'Funded' }, + { index: 2, label: 'Smart Contract Audit', amount: '800', token: 'USDC', status: 'Pending' }, + { index: 3, label: 'Deployment & QA', amount: '500', token: 'USDC', status: 'Pending' }, +] + const Dashboard: NextPage = () => { const [sidebarOpen, setSidebarOpen] = useState(false) const { price: usdcPrice, status: priceStatus } = useUSDCPrice() + const [lastRollback, setLastRollback] = useState(null) + const [milestones] = useState(INITIAL_MILESTONES) const escrowUSD = usdcPrice !== null ? formatUSD(convertToUSD(ESCROW_USDC, usdcPrice)) : null @@ -69,6 +92,94 @@ const Dashboard: NextPage = () => { console.error('IPFS pin failed for', entry.file.name, entry.error) } + // ── Optimistic tracker helpers ──────────────────────────── + + const getEffective = useCallback( + (milestoneIndex: number): MilestoneStatus => { + const key: MilestoneKey = { gigId: DEMO_GIG_ID, milestoneIndex } + const ms = milestones.find((m) => m.index === milestoneIndex) + return getEffectiveStatus(key, ms?.status ?? 'Pending') + }, + [milestones], + ) + + const checkOptimistic = useCallback( + (milestoneIndex: number): boolean => { + const key: MilestoneKey = { gigId: DEMO_GIG_ID, milestoneIndex } + return getOptimisticState(key)?.isOptimistic ?? false + }, + [], + ) + + /** + * Simulate an optimistic escrow action. Applies the optimistic update + * immediately, then after a short delay simulates the on-chain confirmation + * (or a random rollback for demo purposes). + */ + const simulateAction = useCallback( + (milestoneIndex: number, currentStatus: MilestoneStatus, nextStatus: MilestoneStatus) => { + const key: MilestoneKey = { gigId: DEMO_GIG_ID, milestoneIndex } + + try { + applyOptimisticUpdate(key, currentStatus, nextStatus) + } catch (err) { + console.error('Invalid transition:', err) + return + } + + // Simulate a 2-second on-chain confirmation (20% chance of failure for demo) + setTimeout(() => { + const shouldFail = Math.random() < 0.2 + if (shouldFail) { + const reason = 'Simulated ledger failure: transaction rejected by network' + rollbackUpdate(key, reason) + setLastRollback({ + key, + previousStatus: currentStatus, + attemptedStatus: nextStatus, + reason, + rolledBackAt: Date.now(), + }) + } else { + confirmUpdate(key) + } + }, 2000) + }, + [], + ) + + const handleFund = useCallback( + (milestoneIndex: number) => { + const ms = milestones.find((m) => m.index === milestoneIndex) + if (ms) simulateAction(milestoneIndex, ms.status, 'Funded') + }, + [milestones, simulateAction], + ) + + const handleRelease = useCallback( + (milestoneIndex: number) => { + const ms = milestones.find((m) => m.index === milestoneIndex) + if (ms) simulateAction(milestoneIndex, ms.status, 'Released') + }, + [milestones, simulateAction], + ) + + const handleDispute = useCallback( + (milestoneIndex: number) => { + const ms = milestones.find((m) => m.index === milestoneIndex) + if (ms) simulateAction(milestoneIndex, ms.status, 'Disputed') + }, + [milestones, simulateAction], + ) + + const handleRefund = useCallback( + (milestoneIndex: number) => { + const ms = milestones.find((m) => m.index === milestoneIndex) + if (ms) simulateAction(milestoneIndex, ms.status, 'Refunded') + }, + [milestones, simulateAction], + ) + return ( <> @@ -207,6 +318,30 @@ const Dashboard: NextPage = () => { )} + {/* Escrow Milestone Tracker — optimistic UI demo */} +
+
+

+ Escrow Milestones +

+

+ Track milestone progress with instant optimistic updates. Actions apply immediately + and roll back automatically if the on-chain transaction fails. +

+
+ +
+ {/* Empty state */}
💼
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 From fd78959d8c8b8a356ad8c058c37a611bf72d45fd Mon Sep 17 00:00:00 2001 From: dahgold001 Date: Thu, 27 Aug 2026 12:26:59 +0100 Subject: [PATCH 2/4] chore: Sync package-lock.json for npm 10 compatibility in CI --- package-lock.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index 86af925..312f44b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12194,6 +12194,17 @@ "node": ">=10" } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [ From 6ff5f456a837a702a0497129691569cd098251d2 Mon Sep 17 00:00:00 2001 From: dahgold001 Date: Thu, 27 Aug 2026 13:59:37 +0100 Subject: [PATCH 3/4] fix: Add jest-util to devDependencies to fix CI test failure --- package-lock.json | 122 +++++++++++++++++++++++++++++++++++++++++----- package.json | 1 + 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 312f44b..03ccd78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "isomorphic-fetch": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-util": "^30.4.1", "postcss": "^8.5.14", "sharp": "^0.35.2", "tailwindcss": "^3.4.19", @@ -1756,6 +1757,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", @@ -2070,6 +2085,19 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/source-map": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", @@ -2310,6 +2338,25 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "dev": true, @@ -3040,6 +3087,13 @@ "version": "1.21.5", "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -4762,6 +4816,22 @@ "node": ">= 6" } }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/client-only": { "version": "0.0.1", "license": "MIT" @@ -9239,6 +9309,16 @@ } } }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-resolve": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", @@ -10439,6 +10519,37 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-validate": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", @@ -12194,17 +12305,6 @@ "node": ">=10" } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [ diff --git a/package.json b/package.json index 2cde510..e5c7fbf 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "isomorphic-fetch": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-util": "^30.4.1", "postcss": "^8.5.14", "sharp": "^0.35.2", "tailwindcss": "^3.4.19", From cc280bb0a05f16a5d7b35e7f3e596baa75bb2409 Mon Sep 17 00:00:00 2001 From: dahgold001 Date: Thu, 27 Aug 2026 14:11:13 +0100 Subject: [PATCH 4/4] fix: use npm install in CI to prevent strict lockfile sync issues and update lockfile --- .github/workflows/ci.yml | 2 +- package-lock.json | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18d87bc..6233a8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: npm-${{ runner.os }}-node-${{ matrix.node-version }}- - name: Install dependencies - run: npm ci + run: npm install - name: Verify contract bindings are up-to-date run: npm run codegen:validate diff --git a/package-lock.json b/package-lock.json index 03ccd78..d577a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12305,6 +12305,17 @@ "node": ">=10" } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "funding": [