diff --git a/demos/freelance-escrow/.env.example b/demos/freelance-escrow/.env.example index bb4e4aa..099eb34 100644 --- a/demos/freelance-escrow/.env.example +++ b/demos/freelance-escrow/.env.example @@ -3,3 +3,7 @@ VITE_SOROBAN_RPC_URL= VITE_NETWORK_PASSPHRASE= VITE_THOLOS_CONTRACT_ID= +# challenge_window_secs your Tholos instance was initialized with (see +# docs/src/DEPLOYMENT.md). Only used for a client-side "ready to finalize" +# hint; leave unset to assume the canonical testnet deployment's 21600s (6h). +VITE_CHALLENGE_WINDOW_SECS= diff --git a/demos/freelance-escrow/src/App.css b/demos/freelance-escrow/src/App.css index c58ede1..07d01ca 100644 --- a/demos/freelance-escrow/src/App.css +++ b/demos/freelance-escrow/src/App.css @@ -224,6 +224,31 @@ font-family: var(--mono); } +.milestone-ready { + font-weight: 600; + color: var(--success); +} + +.button--refresh { + border: none; + background: none; + color: var(--text); + font: inherit; + font-size: 12px; + text-decoration: underline; + cursor: pointer; + padding: 0; +} + +.button--refresh:hover { + color: var(--accent); +} + +.button--refresh:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .status-badge { padding: 2px 9px; border-radius: 999px; diff --git a/demos/freelance-escrow/src/components/MilestoneRow.tsx b/demos/freelance-escrow/src/components/MilestoneRow.tsx index e9a7145..b20e2ad 100644 --- a/demos/freelance-escrow/src/components/MilestoneRow.tsx +++ b/demos/freelance-escrow/src/components/MilestoneRow.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { Milestone } from "../data/jobs"; import { useJobs } from "../state/useJobs"; import { useRole } from "../state/useRole"; import { useWallet } from "../hooks/useWallet"; +import { CHALLENGE_WINDOW_SECS } from "../lib/config"; const STATUS_LABEL: Record = { in_progress: "In progress", @@ -12,10 +13,17 @@ const STATUS_LABEL: Record = { returned: "Returned to client", }; +/** How often to re-read on-chain state for a milestone that isn't settled yet. */ +const POLL_INTERVAL_MS = 30_000; + +function isSettled(status: Milestone["status"]): boolean { + return status === "released" || status === "returned"; +} + export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: Milestone }) { const { wallet } = useWallet(); const [role] = useRole(); - const { submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone } = useJobs(); + const { submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone, refreshMilestone } = useJobs(); const [busy, setBusy] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -37,6 +45,35 @@ export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: M } } + const assertionId = milestone.assertionId; + const settled = isSettled(milestone.status); + + /** + * Reconcile against real on-chain state on an interval for any milestone + * that has an assertion and isn't settled yet, so status advances even + * when nothing happened in this tab: someone else's dispute, vote, or + * finalize call landing, or a challenge window quietly expiring. + */ + useEffect(() => { + if (!address || !assertionId || settled) { + return; + } + const id = setInterval(() => { + refreshMilestone(jobId, milestone.id, address); + }, POLL_INTERVAL_MS); + return () => clearInterval(id); + }, [address, assertionId, settled, jobId, milestone.id, refreshMilestone]); + + // The contract has no getter for its own configured challenge window (see + // lib/config.ts), so this is a client-side estimate off a real + // Assertion.opened_at read — a hint, not a gate. The "Finalize and + // release" call below is always the real gate; the contract rejects it + // outright if called early. + const readyToFinalize = + milestone.status === "submitted" && + milestone.assertionOpenedAt !== undefined && + Date.now() >= Number(milestone.assertionOpenedAt) * 1000 + CHALLENGE_WINDOW_SECS * 1000; + return (
  • @@ -55,6 +92,16 @@ export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: M {milestone.assertionId && ( assertion #{milestone.assertionId} )} + {readyToFinalize && ready to finalize} + {assertionId && !settled && ( + + )}
    diff --git a/demos/freelance-escrow/src/data/jobs.ts b/demos/freelance-escrow/src/data/jobs.ts index 5ca9079..7818610 100644 --- a/demos/freelance-escrow/src/data/jobs.ts +++ b/demos/freelance-escrow/src/data/jobs.ts @@ -19,6 +19,15 @@ export interface Milestone { * kept client-side per the pattern in docs/src/INTEGRATION.md. */ assertionId?: string; + /** + * `Assertion.opened_at` (ledger timestamp, seconds) from the most recent + * `get_assertion_state` read. Used only to derive a "review window has + * likely closed" hint client-side (see VITE_CHALLENGE_WINDOW_SECS in + * lib/config.ts) since the contract exposes no getter for the configured + * challenge window itself. Never authoritative for whether `finalize` + * will actually succeed — the contract is. + */ + assertionOpenedAt?: string; } export interface Job { diff --git a/demos/freelance-escrow/src/lib/config.ts b/demos/freelance-escrow/src/lib/config.ts index 21ecaa8..bc48415 100644 --- a/demos/freelance-escrow/src/lib/config.ts +++ b/demos/freelance-escrow/src/lib/config.ts @@ -8,3 +8,32 @@ export const RPC_URL = import.meta.env.VITE_SOROBAN_RPC_URL ?? "https://soroban- export const NETWORK_PASSPHRASE = import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; export const THOLOS_CONTRACT_ID: string = import.meta.env.VITE_THOLOS_CONTRACT_ID ?? ""; + +/** + * `challenge_window_secs` as configured on the deployed contract instance. + * The contract has no public getter for this (it's a deploy-time parameter, + * see docs/src/DEPLOYMENT.md), so it's mirrored here the same way the + * contract id itself is: env-configurable, defaulting to the canonical + * testnet deployment's value (21600s / 6h). Used only to derive a + * client-side "review window has likely closed" hint from a real + * `Assertion.opened_at` read; it never gates the `finalize` call itself — + * the contract remains the source of truth and rejects it if called early. + */ +const DEFAULT_CHALLENGE_WINDOW_SECS = 21600; + +function parseChallengeWindowSecs(): number { + const raw = import.meta.env.VITE_CHALLENGE_WINDOW_SECS; + if (!raw) { + return DEFAULT_CHALLENGE_WINDOW_SECS; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + console.warn( + `Invalid VITE_CHALLENGE_WINDOW_SECS "${raw}"; falling back to default (${DEFAULT_CHALLENGE_WINDOW_SECS}s).`, + ); + return DEFAULT_CHALLENGE_WINDOW_SECS; + } + return parsed; +} + +export const CHALLENGE_WINDOW_SECS: number = parseChallengeWindowSecs(); diff --git a/demos/freelance-escrow/src/state/JobsContext.tsx b/demos/freelance-escrow/src/state/JobsContext.tsx index 007d632..db9ead0 100644 --- a/demos/freelance-escrow/src/state/JobsContext.tsx +++ b/demos/freelance-escrow/src/state/JobsContext.tsx @@ -1,6 +1,7 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { jobs as seedJobs, type Job, type Milestone, type MilestoneStatus } from "../data/jobs"; import { JobsContext, type JobsContextValue, type NewJobInput } from "./jobs-context"; +import type { Assertion } from "../lib/tholos"; /** * lib/tholos.ts pulls in the full Stellar SDK. Importing it dynamically, only @@ -33,8 +34,105 @@ function findMilestone(jobs: Job[], jobId: string, milestoneId: string): Milesto return jobs.find((job) => job.id === jobId)?.milestones.find((m) => m.id === milestoneId); } +/** + * The one place that turns a real `Assertion` read into local milestone + * state. `status` is Pending/Disputed/Resolved on-chain, never anything + * about "challenge window elapsed" (the contract doesn't track that as a + * transition, only `finalize` does), so a still-`Pending` assertion always + * maps back to `submitted` here regardless of how much time has passed — + * the "ready to finalize" hint in MilestoneRow is a separate, client-side + * computation over `opened_at` and is never sourced from `status`. + */ +function mapAssertionToPatch(assertion: Assertion): Partial { + const assertionOpenedAt = assertion.opened_at.toString(); + if (assertion.status.tag === "Disputed") { + return { status: "disputed" satisfies MilestoneStatus, assertionOpenedAt }; + } + if (assertion.status.tag === "Resolved") { + // `final_outcome` is guaranteed `Some` once `status` is `Resolved` (see + // docs/src/INTEGRATION.md#reading-the-outcome); `true` means the + // asserter's original claim stood (the freelancer's "done"), `false` + // means it didn't. + return { + status: (assertion.final_outcome ? "released" : "returned") satisfies MilestoneStatus, + assertionOpenedAt, + }; + } + return { status: "submitted" satisfies MilestoneStatus, assertionOpenedAt }; +} + +/** + * Per-JobsProvider bookkeeping shared by every reconcileFromChain call: + * `counter` hands each call a strictly increasing id the moment it starts + * (so issue order across concurrent calls — a background poll vs. an + * action's own reconcile — is always resolvable), and `applied` remembers + * the highest id actually written to state per milestone, so a result is + * only ever dropped when a *later-issued* result has *already applied* — + * never just because another call is merely in flight. + */ +interface ReconcileTracker { + counter: number; + applied: Map; +} + +/** + * Re-reads real on-chain state for one milestone's assertion and reconciles + * local status from it. Used both right after an action (instead of trusting + * a hardcoded guess about what the call must have done) and from background + * polling / a manual refresh — one code path either way, so a background + * poll and an action-triggered reconcile can genuinely be in flight for the + * same milestone at once. + * + * Two failure modes this guards against: + * - Out-of-order responses: `tracker` drops a response whose call was + * superseded by a later-issued call that has already applied its result, + * so a slow stale poll response can never overwrite a fresher one. + * - A failed read right after a call whose contract invocation already + * returned the real, deterministic outcome (finalize's and resolve's own + * return values, not a guess about what they must have done): if the + * caller passes `fallbackPatch` built from that value, it's applied + * instead of leaving the UI on stale pre-action status with nothing but a + * console.warn to show for it. + */ +async function reconcileFromChain( + setJobs: Dispatch>, + tracker: { current: ReconcileTracker }, + jobId: string, + milestoneId: string, + assertionId: string, + readAs: string, + fallbackPatch?: Partial, +): Promise { + const key = `${jobId}:${milestoneId}`; + const mySeq = ++tracker.current.counter; + + function applyIfNewest(patch: Partial) { + if (mySeq <= (tracker.current.applied.get(key) ?? 0)) { + return; + } + tracker.current.applied.set(key, mySeq); + setJobs((current) => updateMilestone(current, jobId, milestoneId, patch)); + } + + try { + const { getAssertionState } = await loadTholosClient(); + const assertion = await getAssertionState(BigInt(assertionId), readAs); + applyIfNewest(mapAssertionToPatch(assertion)); + } catch (err) { + console.warn( + `Could not read back on-chain state for milestone ${milestoneId} (assertion ${assertionId})` + + (fallbackPatch ? "; applying the already-known result instead." : "; will retry on next refresh."), + err, + ); + if (fallbackPatch) { + applyIfNewest(fallbackPatch); + } + } +} + export function JobsProvider({ children }: { children: ReactNode }) { const [jobs, setJobs] = useState(seedJobs); + const reconcileTrackerRef = useRef({ counter: 0, applied: new Map() }); const createJob = useCallback((input: NewJobInput) => { const jobId = `job-${crypto.randomUUID()}`; @@ -58,6 +156,10 @@ export function JobsProvider({ children }: { children: ReactNode }) { const submitMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => { const { assertOutcome } = await loadTholosClient(); const assertionId = (await assertOutcome(signerAddress, true)).toString(); + // assert_outcome succeeding guarantees a fresh Pending assertion exists; + // that much is certain, so it's set immediately rather than waiting on a + // round-trip. Everything else (and opened_at, needed for the + // finalize-eligibility hint) comes from a real read right after. setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "submitted" satisfies MilestoneStatus, @@ -65,6 +167,7 @@ export function JobsProvider({ children }: { children: ReactNode }) { assertionId, }), ); + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, assertionId, signerAddress); }, []); const disputeMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => { @@ -74,7 +177,12 @@ export function JobsProvider({ children }: { children: ReactNode }) { } const { disputeAssertion } = await loadTholosClient(); await disputeAssertion(signerAddress, BigInt(milestone.assertionId)); - setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "disputed" })); + // dispute succeeding guarantees Disputed; reconcile picks up the rest + // (and corrects this if, improbably, something else changed it first). + setJobs((current) => + updateMilestone(current, jobId, milestoneId, { status: "disputed" satisfies MilestoneStatus }), + ); + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, milestone.assertionId, signerAddress); }, [jobs]); const voteOnMilestone = useCallback( @@ -86,12 +194,20 @@ export function JobsProvider({ children }: { children: ReactNode }) { const { resolveAssertion } = await loadTholosClient(); const decided = await resolveAssertion(resolverAddress, BigInt(milestone.assertionId), agreesWithFreelancer); if (decided === null) { + // Majority not reached yet; still Disputed, nothing to reconcile. return; } - setJobs((current) => - updateMilestone(current, jobId, milestoneId, { - status: decided ? "released" : "returned", - }), + // resolve succeeding with a non-null verdict guarantees the same + // outcome mapping mapAssertionToPatch uses for a Resolved assertion; + // pass it as the known fallback in case the follow-up read fails. + await reconcileFromChain( + setJobs, + reconcileTrackerRef, + jobId, + milestoneId, + milestone.assertionId, + resolverAddress, + { status: (decided ? "released" : "returned") satisfies MilestoneStatus }, ); }, [jobs], @@ -103,10 +219,38 @@ export function JobsProvider({ children }: { children: ReactNode }) { return; } const { finalizeAssertion } = await loadTholosClient(); - await finalizeAssertion(callerAddress, BigInt(milestone.assertionId)); - setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "released" })); + const outcome = await finalizeAssertion(callerAddress, BigInt(milestone.assertionId)); + // finalizeAssertion already returns the contract's own outcome for this + // assertion (same true/false meaning as mapAssertionToPatch's Resolved + // case) — use that real result as the known fallback in case the + // follow-up read fails, instead of assuming what it must have been. + await reconcileFromChain( + setJobs, + reconcileTrackerRef, + jobId, + milestoneId, + milestone.assertionId, + callerAddress, + { status: (outcome ? "released" : "returned") satisfies MilestoneStatus }, + ); }, [jobs]); + // Kept in sync with `jobs` on every render, but deliberately not a + // dependency of `refreshMilestone` below: that callback is held in a + // MilestoneRow's polling-interval effect, and if its identity changed + // every time *any* milestone's state changed, one milestone's refresh + // would reset every other actively-polling row's timer. + const jobsRef = useRef(jobs); + jobsRef.current = jobs; + + const refreshMilestone = useCallback(async (jobId: string, milestoneId: string, readAs: string) => { + const milestone = findMilestone(jobsRef.current, jobId, milestoneId); + if (!milestone?.assertionId) { + return; + } + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, milestone.assertionId, readAs); + }, []); + const value = useMemo( () => ({ jobs, @@ -115,8 +259,9 @@ export function JobsProvider({ children }: { children: ReactNode }) { disputeMilestone, voteOnMilestone, finalizeMilestone, + refreshMilestone, }), - [jobs, createJob, submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone], + [jobs, createJob, submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone, refreshMilestone], ); return {children}; diff --git a/demos/freelance-escrow/src/state/jobs-context.ts b/demos/freelance-escrow/src/state/jobs-context.ts index 9e5a1bb..dd56e3d 100644 --- a/demos/freelance-escrow/src/state/jobs-context.ts +++ b/demos/freelance-escrow/src/state/jobs-context.ts @@ -22,6 +22,15 @@ export interface JobsContextValue { agreesWithFreelancer: boolean, ) => Promise; finalizeMilestone: (jobId: string, milestoneId: string, callerAddress: string) => Promise; + /** + * Re-reads the real on-chain assertion state for this milestone and + * reconciles local status from it, instead of trusting whatever the last + * optimistic write assumed. Safe to call on any cadence (poll, manual + * refresh button, or right after an action) — a no-op if the milestone + * has no assertionId yet. `readAs` only needs to be a connected wallet + * address; simulation needs a source account but never signs or spends. + */ + refreshMilestone: (jobId: string, milestoneId: string, readAs: string) => Promise; } export const JobsContext = createContext(null);