From 96d2bddcd609ad6fceb08f989f87df11266a76e0 Mon Sep 17 00:00:00 2001 From: JohnArayaE Date: Wed, 2 Sep 2026 21:29:41 -0600 Subject: [PATCH 1/3] Reconcile milestone status against real on-chain assertion state JobsContext previously set local milestone status from the assumed result of a transaction, without ever reading get_assertion_state back. Now every action (submit/dispute/vote/finalize) reconciles from a real read afterward, and any milestone with an assertionId that isn't settled gets polled every 30s so status advances even when another party's action or an expired challenge window is what changed it. Adds a client-side, env-configurable challenge-window hint for finalize eligibility, since the contract has no getter for it. Closes #147 --- demos/freelance-escrow/.env.example | 4 + demos/freelance-escrow/src/App.css | 25 +++++ .../src/components/MilestoneRow.tsx | 51 +++++++++- demos/freelance-escrow/src/data/jobs.ts | 9 ++ demos/freelance-escrow/src/lib/config.ts | 14 +++ .../src/state/JobsContext.tsx | 92 +++++++++++++++++-- .../src/state/jobs-context.ts | 9 ++ 7 files changed, 193 insertions(+), 11 deletions(-) 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 c42e3af..748355e 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..867c429 100644 --- a/demos/freelance-escrow/src/lib/config.ts +++ b/demos/freelance-escrow/src/lib/config.ts @@ -8,3 +8,17 @@ 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. + */ +export const CHALLENGE_WINDOW_SECS: number = import.meta.env.VITE_CHALLENGE_WINDOW_SECS + ? Number(import.meta.env.VITE_CHALLENGE_WINDOW_SECS) + : 21600; diff --git a/demos/freelance-escrow/src/state/JobsContext.tsx b/demos/freelance-escrow/src/state/JobsContext.tsx index 007d632..0ff0110 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, 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,6 +34,63 @@ 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 }; +} + +/** + * 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. + * + * Deliberately swallows read failures: by the time this runs, the action + * that triggered it (if any) has already succeeded on-chain, so surfacing a + * transient RPC error here would misreport a successful transaction as + * failed. Whoever's polling will retry on the next tick. + */ +async function reconcileFromChain( + setJobs: Dispatch>, + jobId: string, + milestoneId: string, + assertionId: string, + readAs: string, +): Promise { + try { + const { getAssertionState } = await loadTholosClient(); + const assertion = await getAssertionState(BigInt(assertionId), readAs); + setJobs((current) => updateMilestone(current, jobId, milestoneId, mapAssertionToPatch(assertion))); + } catch (err) { + console.warn( + `Could not read back on-chain state for milestone ${milestoneId} (assertion ${assertionId}); will retry on next refresh.`, + err, + ); + } +} + export function JobsProvider({ children }: { children: ReactNode }) { const [jobs, setJobs] = useState(seedJobs); @@ -58,6 +116,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 +127,7 @@ export function JobsProvider({ children }: { children: ReactNode }) { assertionId, }), ); + await reconcileFromChain(setJobs, jobId, milestoneId, assertionId, signerAddress); }, []); const disputeMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => { @@ -74,7 +137,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, jobId, milestoneId, milestone.assertionId, signerAddress); }, [jobs]); const voteOnMilestone = useCallback( @@ -86,13 +154,10 @@ 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", - }), - ); + await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, resolverAddress); }, [jobs], ); @@ -104,7 +169,15 @@ export function JobsProvider({ children }: { children: ReactNode }) { } const { finalizeAssertion } = await loadTholosClient(); await finalizeAssertion(callerAddress, BigInt(milestone.assertionId)); - setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "released" })); + await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, callerAddress); + }, [jobs]); + + const refreshMilestone = useCallback(async (jobId: string, milestoneId: string, readAs: string) => { + const milestone = findMilestone(jobs, jobId, milestoneId); + if (!milestone?.assertionId) { + return; + } + await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, readAs); }, [jobs]); const value = useMemo( @@ -115,8 +188,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); From b68aa16432f2a9b0d1ef75571ddb8b94f442cf97 Mon Sep 17 00:00:00 2001 From: JohnArayaE Date: Fri, 4 Sep 2026 18:39:25 -0600 Subject: [PATCH 2/3] Address review: stabilize refreshMilestone and validate CHALLENGE_WINDOW_SECS --- demos/freelance-escrow/src/lib/config.ts | 21 ++++++++++++++++--- .../src/state/JobsContext.tsx | 14 ++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/demos/freelance-escrow/src/lib/config.ts b/demos/freelance-escrow/src/lib/config.ts index 867c429..bc48415 100644 --- a/demos/freelance-escrow/src/lib/config.ts +++ b/demos/freelance-escrow/src/lib/config.ts @@ -19,6 +19,21 @@ export const THOLOS_CONTRACT_ID: string = import.meta.env.VITE_THOLOS_CONTRACT_I * `Assertion.opened_at` read; it never gates the `finalize` call itself — * the contract remains the source of truth and rejects it if called early. */ -export const CHALLENGE_WINDOW_SECS: number = import.meta.env.VITE_CHALLENGE_WINDOW_SECS - ? Number(import.meta.env.VITE_CHALLENGE_WINDOW_SECS) - : 21600; +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 0ff0110..50bfb62 100644 --- a/demos/freelance-escrow/src/state/JobsContext.tsx +++ b/demos/freelance-escrow/src/state/JobsContext.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } 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"; @@ -172,13 +172,21 @@ export function JobsProvider({ children }: { children: ReactNode }) { await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, callerAddress); }, [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(jobs, jobId, milestoneId); + const milestone = findMilestone(jobsRef.current, jobId, milestoneId); if (!milestone?.assertionId) { return; } await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, readAs); - }, [jobs]); + }, []); const value = useMemo( () => ({ From d96c818ffabdd4f4451e958ffd4a5f6cd463e844 Mon Sep 17 00:00:00 2001 From: JohnArayaE Date: Sat, 5 Sep 2026 15:55:36 -0600 Subject: [PATCH 3/3] Address review: fallback to deterministic outcome on reconcile-read failure, guard against out-of-order reconciles --- .../src/state/JobsContext.tsx | 89 ++++++++++++++++--- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/demos/freelance-escrow/src/state/JobsContext.tsx b/demos/freelance-escrow/src/state/JobsContext.tsx index 50bfb62..db9ead0 100644 --- a/demos/freelance-escrow/src/state/JobsContext.tsx +++ b/demos/freelance-escrow/src/state/JobsContext.tsx @@ -61,38 +61,78 @@ function mapAssertionToPatch(assertion: Assertion): Partial { 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. + * 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. * - * Deliberately swallows read failures: by the time this runs, the action - * that triggered it (if any) has already succeeded on-chain, so surfacing a - * transient RPC error here would misreport a successful transaction as - * failed. Whoever's polling will retry on the next tick. + * 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); - setJobs((current) => updateMilestone(current, jobId, milestoneId, mapAssertionToPatch(assertion))); + applyIfNewest(mapAssertionToPatch(assertion)); } catch (err) { console.warn( - `Could not read back on-chain state for milestone ${milestoneId} (assertion ${assertionId}); will retry on next refresh.`, + `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()}`; @@ -127,7 +167,7 @@ export function JobsProvider({ children }: { children: ReactNode }) { assertionId, }), ); - await reconcileFromChain(setJobs, jobId, milestoneId, assertionId, signerAddress); + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, assertionId, signerAddress); }, []); const disputeMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => { @@ -142,7 +182,7 @@ export function JobsProvider({ children }: { children: ReactNode }) { setJobs((current) => updateMilestone(current, jobId, milestoneId, { status: "disputed" satisfies MilestoneStatus }), ); - await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, signerAddress); + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, milestone.assertionId, signerAddress); }, [jobs]); const voteOnMilestone = useCallback( @@ -157,7 +197,18 @@ export function JobsProvider({ children }: { children: ReactNode }) { // Majority not reached yet; still Disputed, nothing to reconcile. return; } - await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, resolverAddress); + // 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], ); @@ -168,8 +219,20 @@ export function JobsProvider({ children }: { children: ReactNode }) { return; } const { finalizeAssertion } = await loadTholosClient(); - await finalizeAssertion(callerAddress, BigInt(milestone.assertionId)); - await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, callerAddress); + 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 @@ -185,7 +248,7 @@ export function JobsProvider({ children }: { children: ReactNode }) { if (!milestone?.assertionId) { return; } - await reconcileFromChain(setJobs, jobId, milestoneId, milestone.assertionId, readAs); + await reconcileFromChain(setJobs, reconcileTrackerRef, jobId, milestoneId, milestone.assertionId, readAs); }, []); const value = useMemo(