1- import { useCallback , useMemo , useState , type ReactNode } from "react" ;
1+ import { useCallback , useMemo , useRef , useState , type Dispatch , type ReactNode , type SetStateAction } from "react" ;
22import { jobs as seedJobs , type Job , type Milestone , type MilestoneStatus } from "../data/jobs" ;
33import { JobsContext , type JobsContextValue , type NewJobInput } from "./jobs-context" ;
4+ import type { Assertion } from "../lib/tholos" ;
45
56/**
67 * 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
3334 return jobs . find ( ( job ) => job . id === jobId ) ?. milestones . find ( ( m ) => m . id === milestoneId ) ;
3435}
3536
37+ /**
38+ * The one place that turns a real `Assertion` read into local milestone
39+ * state. `status` is Pending/Disputed/Resolved on-chain, never anything
40+ * about "challenge window elapsed" (the contract doesn't track that as a
41+ * transition, only `finalize` does), so a still-`Pending` assertion always
42+ * maps back to `submitted` here regardless of how much time has passed —
43+ * the "ready to finalize" hint in MilestoneRow is a separate, client-side
44+ * computation over `opened_at` and is never sourced from `status`.
45+ */
46+ function mapAssertionToPatch ( assertion : Assertion ) : Partial < Milestone > {
47+ const assertionOpenedAt = assertion . opened_at . toString ( ) ;
48+ if ( assertion . status . tag === "Disputed" ) {
49+ return { status : "disputed" satisfies MilestoneStatus , assertionOpenedAt } ;
50+ }
51+ if ( assertion . status . tag === "Resolved" ) {
52+ // `final_outcome` is guaranteed `Some` once `status` is `Resolved` (see
53+ // docs/src/INTEGRATION.md#reading-the-outcome); `true` means the
54+ // asserter's original claim stood (the freelancer's "done"), `false`
55+ // means it didn't.
56+ return {
57+ status : ( assertion . final_outcome ? "released" : "returned" ) satisfies MilestoneStatus ,
58+ assertionOpenedAt,
59+ } ;
60+ }
61+ return { status : "submitted" satisfies MilestoneStatus , assertionOpenedAt } ;
62+ }
63+
64+ /**
65+ * Per-JobsProvider bookkeeping shared by every reconcileFromChain call:
66+ * `counter` hands each call a strictly increasing id the moment it starts
67+ * (so issue order across concurrent calls — a background poll vs. an
68+ * action's own reconcile — is always resolvable), and `applied` remembers
69+ * the highest id actually written to state per milestone, so a result is
70+ * only ever dropped when a *later-issued* result has *already applied* —
71+ * never just because another call is merely in flight.
72+ */
73+ interface ReconcileTracker {
74+ counter : number ;
75+ applied : Map < string , number > ;
76+ }
77+
78+ /**
79+ * Re-reads real on-chain state for one milestone's assertion and reconciles
80+ * local status from it. Used both right after an action (instead of trusting
81+ * a hardcoded guess about what the call must have done) and from background
82+ * polling / a manual refresh — one code path either way, so a background
83+ * poll and an action-triggered reconcile can genuinely be in flight for the
84+ * same milestone at once.
85+ *
86+ * Two failure modes this guards against:
87+ * - Out-of-order responses: `tracker` drops a response whose call was
88+ * superseded by a later-issued call that has already applied its result,
89+ * so a slow stale poll response can never overwrite a fresher one.
90+ * - A failed read right after a call whose contract invocation already
91+ * returned the real, deterministic outcome (finalize's and resolve's own
92+ * return values, not a guess about what they must have done): if the
93+ * caller passes `fallbackPatch` built from that value, it's applied
94+ * instead of leaving the UI on stale pre-action status with nothing but a
95+ * console.warn to show for it.
96+ */
97+ async function reconcileFromChain (
98+ setJobs : Dispatch < SetStateAction < Job [ ] > > ,
99+ tracker : { current : ReconcileTracker } ,
100+ jobId : string ,
101+ milestoneId : string ,
102+ assertionId : string ,
103+ readAs : string ,
104+ fallbackPatch ?: Partial < Milestone > ,
105+ ) : Promise < void > {
106+ const key = `${ jobId } :${ milestoneId } ` ;
107+ const mySeq = ++ tracker . current . counter ;
108+
109+ function applyIfNewest ( patch : Partial < Milestone > ) {
110+ if ( mySeq <= ( tracker . current . applied . get ( key ) ?? 0 ) ) {
111+ return ;
112+ }
113+ tracker . current . applied . set ( key , mySeq ) ;
114+ setJobs ( ( current ) => updateMilestone ( current , jobId , milestoneId , patch ) ) ;
115+ }
116+
117+ try {
118+ const { getAssertionState } = await loadTholosClient ( ) ;
119+ const assertion = await getAssertionState ( BigInt ( assertionId ) , readAs ) ;
120+ applyIfNewest ( mapAssertionToPatch ( assertion ) ) ;
121+ } catch ( err ) {
122+ console . warn (
123+ `Could not read back on-chain state for milestone ${ milestoneId } (assertion ${ assertionId } )` +
124+ ( fallbackPatch ? "; applying the already-known result instead." : "; will retry on next refresh." ) ,
125+ err ,
126+ ) ;
127+ if ( fallbackPatch ) {
128+ applyIfNewest ( fallbackPatch ) ;
129+ }
130+ }
131+ }
132+
36133export function JobsProvider ( { children } : { children : ReactNode } ) {
37134 const [ jobs , setJobs ] = useState < Job [ ] > ( seedJobs ) ;
135+ const reconcileTrackerRef = useRef < ReconcileTracker > ( { counter : 0 , applied : new Map ( ) } ) ;
38136
39137 const createJob = useCallback ( ( input : NewJobInput ) => {
40138 const jobId = `job-${ crypto . randomUUID ( ) } ` ;
@@ -58,13 +156,18 @@ export function JobsProvider({ children }: { children: ReactNode }) {
58156 const submitMilestone = useCallback ( async ( jobId : string , milestoneId : string , signerAddress : string ) => {
59157 const { assertOutcome } = await loadTholosClient ( ) ;
60158 const assertionId = ( await assertOutcome ( signerAddress , true ) ) . toString ( ) ;
159+ // assert_outcome succeeding guarantees a fresh Pending assertion exists;
160+ // that much is certain, so it's set immediately rather than waiting on a
161+ // round-trip. Everything else (and opened_at, needed for the
162+ // finalize-eligibility hint) comes from a real read right after.
61163 setJobs ( ( current ) =>
62164 updateMilestone ( current , jobId , milestoneId , {
63165 status : "submitted" satisfies MilestoneStatus ,
64166 submittedAt : new Date ( ) . toISOString ( ) ,
65167 assertionId,
66168 } ) ,
67169 ) ;
170+ await reconcileFromChain ( setJobs , reconcileTrackerRef , jobId , milestoneId , assertionId , signerAddress ) ;
68171 } , [ ] ) ;
69172
70173 const disputeMilestone = useCallback ( async ( jobId : string , milestoneId : string , signerAddress : string ) => {
@@ -74,7 +177,12 @@ export function JobsProvider({ children }: { children: ReactNode }) {
74177 }
75178 const { disputeAssertion } = await loadTholosClient ( ) ;
76179 await disputeAssertion ( signerAddress , BigInt ( milestone . assertionId ) ) ;
77- setJobs ( ( current ) => updateMilestone ( current , jobId , milestoneId , { status : "disputed" } ) ) ;
180+ // dispute succeeding guarantees Disputed; reconcile picks up the rest
181+ // (and corrects this if, improbably, something else changed it first).
182+ setJobs ( ( current ) =>
183+ updateMilestone ( current , jobId , milestoneId , { status : "disputed" satisfies MilestoneStatus } ) ,
184+ ) ;
185+ await reconcileFromChain ( setJobs , reconcileTrackerRef , jobId , milestoneId , milestone . assertionId , signerAddress ) ;
78186 } , [ jobs ] ) ;
79187
80188 const voteOnMilestone = useCallback (
@@ -86,12 +194,20 @@ export function JobsProvider({ children }: { children: ReactNode }) {
86194 const { resolveAssertion } = await loadTholosClient ( ) ;
87195 const decided = await resolveAssertion ( resolverAddress , BigInt ( milestone . assertionId ) , agreesWithFreelancer ) ;
88196 if ( decided === null ) {
197+ // Majority not reached yet; still Disputed, nothing to reconcile.
89198 return ;
90199 }
91- setJobs ( ( current ) =>
92- updateMilestone ( current , jobId , milestoneId , {
93- status : decided ? "released" : "returned" ,
94- } ) ,
200+ // resolve succeeding with a non-null verdict guarantees the same
201+ // outcome mapping mapAssertionToPatch uses for a Resolved assertion;
202+ // pass it as the known fallback in case the follow-up read fails.
203+ await reconcileFromChain (
204+ setJobs ,
205+ reconcileTrackerRef ,
206+ jobId ,
207+ milestoneId ,
208+ milestone . assertionId ,
209+ resolverAddress ,
210+ { status : ( decided ? "released" : "returned" ) satisfies MilestoneStatus } ,
95211 ) ;
96212 } ,
97213 [ jobs ] ,
@@ -103,10 +219,38 @@ export function JobsProvider({ children }: { children: ReactNode }) {
103219 return ;
104220 }
105221 const { finalizeAssertion } = await loadTholosClient ( ) ;
106- await finalizeAssertion ( callerAddress , BigInt ( milestone . assertionId ) ) ;
107- setJobs ( ( current ) => updateMilestone ( current , jobId , milestoneId , { status : "released" } ) ) ;
222+ const outcome = await finalizeAssertion ( callerAddress , BigInt ( milestone . assertionId ) ) ;
223+ // finalizeAssertion already returns the contract's own outcome for this
224+ // assertion (same true/false meaning as mapAssertionToPatch's Resolved
225+ // case) — use that real result as the known fallback in case the
226+ // follow-up read fails, instead of assuming what it must have been.
227+ await reconcileFromChain (
228+ setJobs ,
229+ reconcileTrackerRef ,
230+ jobId ,
231+ milestoneId ,
232+ milestone . assertionId ,
233+ callerAddress ,
234+ { status : ( outcome ? "released" : "returned" ) satisfies MilestoneStatus } ,
235+ ) ;
108236 } , [ jobs ] ) ;
109237
238+ // Kept in sync with `jobs` on every render, but deliberately not a
239+ // dependency of `refreshMilestone` below: that callback is held in a
240+ // MilestoneRow's polling-interval effect, and if its identity changed
241+ // every time *any* milestone's state changed, one milestone's refresh
242+ // would reset every other actively-polling row's timer.
243+ const jobsRef = useRef ( jobs ) ;
244+ jobsRef . current = jobs ;
245+
246+ const refreshMilestone = useCallback ( async ( jobId : string , milestoneId : string , readAs : string ) => {
247+ const milestone = findMilestone ( jobsRef . current , jobId , milestoneId ) ;
248+ if ( ! milestone ?. assertionId ) {
249+ return ;
250+ }
251+ await reconcileFromChain ( setJobs , reconcileTrackerRef , jobId , milestoneId , milestone . assertionId , readAs ) ;
252+ } , [ ] ) ;
253+
110254 const value = useMemo < JobsContextValue > (
111255 ( ) => ( {
112256 jobs,
@@ -115,8 +259,9 @@ export function JobsProvider({ children }: { children: ReactNode }) {
115259 disputeMilestone,
116260 voteOnMilestone,
117261 finalizeMilestone,
262+ refreshMilestone,
118263 } ) ,
119- [ jobs , createJob , submitMilestone , disputeMilestone , voteOnMilestone , finalizeMilestone ] ,
264+ [ jobs , createJob , submitMilestone , disputeMilestone , voteOnMilestone , finalizeMilestone , refreshMilestone ] ,
120265 ) ;
121266
122267 return < JobsContext . Provider value = { value } > { children } </ JobsContext . Provider > ;
0 commit comments