Skip to content

Commit b2570aa

Browse files
authored
fix(demo): reconcile milestone status against real on-chain assertion state
1 parent 73ed4c5 commit b2570aa

7 files changed

Lines changed: 279 additions & 11 deletions

File tree

demos/freelance-escrow/.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
VITE_SOROBAN_RPC_URL=
44
VITE_NETWORK_PASSPHRASE=
55
VITE_THOLOS_CONTRACT_ID=
6+
# challenge_window_secs your Tholos instance was initialized with (see
7+
# docs/src/DEPLOYMENT.md). Only used for a client-side "ready to finalize"
8+
# hint; leave unset to assume the canonical testnet deployment's 21600s (6h).
9+
VITE_CHALLENGE_WINDOW_SECS=

demos/freelance-escrow/src/App.css

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,31 @@
224224
font-family: var(--mono);
225225
}
226226

227+
.milestone-ready {
228+
font-weight: 600;
229+
color: var(--success);
230+
}
231+
232+
.button--refresh {
233+
border: none;
234+
background: none;
235+
color: var(--text);
236+
font: inherit;
237+
font-size: 12px;
238+
text-decoration: underline;
239+
cursor: pointer;
240+
padding: 0;
241+
}
242+
243+
.button--refresh:hover {
244+
color: var(--accent);
245+
}
246+
247+
.button--refresh:disabled {
248+
opacity: 0.5;
249+
cursor: not-allowed;
250+
}
251+
227252
.status-badge {
228253
padding: 2px 9px;
229254
border-radius: 999px;

demos/freelance-escrow/src/components/MilestoneRow.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState } from "react";
1+
import { useEffect, useState } from "react";
22
import type { Milestone } from "../data/jobs";
33
import { useJobs } from "../state/useJobs";
44
import { useRole } from "../state/useRole";
55
import { useWallet } from "../hooks/useWallet";
6+
import { CHALLENGE_WINDOW_SECS } from "../lib/config";
67

78
const STATUS_LABEL: Record<Milestone["status"], string> = {
89
in_progress: "In progress",
@@ -12,10 +13,17 @@ const STATUS_LABEL: Record<Milestone["status"], string> = {
1213
returned: "Returned to client",
1314
};
1415

16+
/** How often to re-read on-chain state for a milestone that isn't settled yet. */
17+
const POLL_INTERVAL_MS = 30_000;
18+
19+
function isSettled(status: Milestone["status"]): boolean {
20+
return status === "released" || status === "returned";
21+
}
22+
1523
export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: Milestone }) {
1624
const { wallet } = useWallet();
1725
const [role] = useRole();
18-
const { submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone } = useJobs();
26+
const { submitMilestone, disputeMilestone, voteOnMilestone, finalizeMilestone, refreshMilestone } = useJobs();
1927
const [busy, setBusy] = useState(false);
2028
const [errorMessage, setErrorMessage] = useState<string | null>(null);
2129

@@ -37,6 +45,35 @@ export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: M
3745
}
3846
}
3947

48+
const assertionId = milestone.assertionId;
49+
const settled = isSettled(milestone.status);
50+
51+
/**
52+
* Reconcile against real on-chain state on an interval for any milestone
53+
* that has an assertion and isn't settled yet, so status advances even
54+
* when nothing happened in this tab: someone else's dispute, vote, or
55+
* finalize call landing, or a challenge window quietly expiring.
56+
*/
57+
useEffect(() => {
58+
if (!address || !assertionId || settled) {
59+
return;
60+
}
61+
const id = setInterval(() => {
62+
refreshMilestone(jobId, milestone.id, address);
63+
}, POLL_INTERVAL_MS);
64+
return () => clearInterval(id);
65+
}, [address, assertionId, settled, jobId, milestone.id, refreshMilestone]);
66+
67+
// The contract has no getter for its own configured challenge window (see
68+
// lib/config.ts), so this is a client-side estimate off a real
69+
// Assertion.opened_at read — a hint, not a gate. The "Finalize and
70+
// release" call below is always the real gate; the contract rejects it
71+
// outright if called early.
72+
const readyToFinalize =
73+
milestone.status === "submitted" &&
74+
milestone.assertionOpenedAt !== undefined &&
75+
Date.now() >= Number(milestone.assertionOpenedAt) * 1000 + CHALLENGE_WINDOW_SECS * 1000;
76+
4077
return (
4178
<li className={`milestone milestone--${milestone.status}`}>
4279
<div className="milestone-main">
@@ -55,6 +92,16 @@ export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: M
5592
{milestone.assertionId && (
5693
<span className="milestone-assertion">assertion #{milestone.assertionId}</span>
5794
)}
95+
{readyToFinalize && <span className="milestone-ready">ready to finalize</span>}
96+
{assertionId && !settled && (
97+
<button
98+
className="button--refresh"
99+
disabled={busy}
100+
onClick={() => run(() => refreshMilestone(jobId, milestone.id, address!))}
101+
>
102+
Refresh
103+
</button>
104+
)}
58105
</div>
59106

60107
<div className="milestone-actions">

demos/freelance-escrow/src/data/jobs.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ export interface Milestone {
1919
* kept client-side per the pattern in docs/src/INTEGRATION.md.
2020
*/
2121
assertionId?: string;
22+
/**
23+
* `Assertion.opened_at` (ledger timestamp, seconds) from the most recent
24+
* `get_assertion_state` read. Used only to derive a "review window has
25+
* likely closed" hint client-side (see VITE_CHALLENGE_WINDOW_SECS in
26+
* lib/config.ts) since the contract exposes no getter for the configured
27+
* challenge window itself. Never authoritative for whether `finalize`
28+
* will actually succeed — the contract is.
29+
*/
30+
assertionOpenedAt?: string;
2231
}
2332

2433
export interface Job {

demos/freelance-escrow/src/lib/config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,32 @@ export const RPC_URL = import.meta.env.VITE_SOROBAN_RPC_URL ?? "https://soroban-
88
export const NETWORK_PASSPHRASE =
99
import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015";
1010
export const THOLOS_CONTRACT_ID: string = import.meta.env.VITE_THOLOS_CONTRACT_ID ?? "";
11+
12+
/**
13+
* `challenge_window_secs` as configured on the deployed contract instance.
14+
* The contract has no public getter for this (it's a deploy-time parameter,
15+
* see docs/src/DEPLOYMENT.md), so it's mirrored here the same way the
16+
* contract id itself is: env-configurable, defaulting to the canonical
17+
* testnet deployment's value (21600s / 6h). Used only to derive a
18+
* client-side "review window has likely closed" hint from a real
19+
* `Assertion.opened_at` read; it never gates the `finalize` call itself —
20+
* the contract remains the source of truth and rejects it if called early.
21+
*/
22+
const DEFAULT_CHALLENGE_WINDOW_SECS = 21600;
23+
24+
function parseChallengeWindowSecs(): number {
25+
const raw = import.meta.env.VITE_CHALLENGE_WINDOW_SECS;
26+
if (!raw) {
27+
return DEFAULT_CHALLENGE_WINDOW_SECS;
28+
}
29+
const parsed = Number(raw);
30+
if (!Number.isFinite(parsed) || parsed < 0) {
31+
console.warn(
32+
`Invalid VITE_CHALLENGE_WINDOW_SECS "${raw}"; falling back to default (${DEFAULT_CHALLENGE_WINDOW_SECS}s).`,
33+
);
34+
return DEFAULT_CHALLENGE_WINDOW_SECS;
35+
}
36+
return parsed;
37+
}
38+
39+
export const CHALLENGE_WINDOW_SECS: number = parseChallengeWindowSecs();

demos/freelance-escrow/src/state/JobsContext.tsx

Lines changed: 154 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useCallback, useMemo, useState, type ReactNode } from "react";
1+
import { useCallback, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
22
import { jobs as seedJobs, type Job, type Milestone, type MilestoneStatus } from "../data/jobs";
33
import { 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+
36133
export 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

Comments
 (0)