Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions demos/freelance-escrow/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
25 changes: 25 additions & 0 deletions demos/freelance-escrow/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 49 additions & 2 deletions demos/freelance-escrow/src/components/MilestoneRow.tsx
Original file line number Diff line number Diff line change
@@ -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<Milestone["status"], string> = {
in_progress: "In progress",
Expand All @@ -12,10 +13,17 @@ const STATUS_LABEL: Record<Milestone["status"], string> = {
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<string | null>(null);

Expand All @@ -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]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshMilestone's identity in JobsContext.tsx gets recreated on every jobs state change, so this effect's dependency array means any single milestone's poll tick or manual refresh resets the setInterval timer for every other actively-polling MilestoneRow on the page. With several submitted or disputed milestones open at once, if refreshes across them land more often than every POLL_INTERVAL_MS in aggregate, no individual milestone's timer ever survives a full interval uninterrupted, so its background reconciliation can be indefinitely postponed even though this looks like it guarantees a poll every interval. Consider stabilizing refreshMilestone with useCallback or useRef so one milestone's refresh doesn't reset another's timer.


// 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 (
<li className={`milestone milestone--${milestone.status}`}>
<div className="milestone-main">
Expand All @@ -55,6 +92,16 @@ export function MilestoneRow({ jobId, milestone }: { jobId: string; milestone: M
{milestone.assertionId && (
<span className="milestone-assertion">assertion #{milestone.assertionId}</span>
)}
{readyToFinalize && <span className="milestone-ready">ready to finalize</span>}
{assertionId && !settled && (
<button
className="button--refresh"
disabled={busy}
onClick={() => run(() => refreshMilestone(jobId, milestone.id, address!))}
>
Refresh
</button>
)}
</div>

<div className="milestone-actions">
Expand Down
9 changes: 9 additions & 0 deletions demos/freelance-escrow/src/data/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
29 changes: 29 additions & 0 deletions demos/freelance-escrow/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
100 changes: 91 additions & 9 deletions demos/freelance-escrow/src/state/JobsContext.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Milestone> {
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<SetStateAction<Job[]>>,
jobId: string,
milestoneId: string,
assertionId: string,
readAs: string,
): Promise<void> {
try {
const { getAssertionState } = await loadTholosClient();
const assertion = await getAssertionState(BigInt(assertionId), readAs);
setJobs((current) => updateMilestone(current, jobId, milestoneId, mapAssertionToPatch(assertion)));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unconditional overwrite with no ordering guard against the read it came from. A background poll and an action-triggered reconcile can both be in flight for the same milestone at once, if the poll's RPC read is slower and resolves after the action's, its stale snapshot overwrites the newer status this same function just set moments earlier, silently reverting the UI (e.g. showing submitted again after a dispute just landed) until the next poll cycle corrects it. Consider tracking a request sequence number or timestamp per milestone and dropping a reconcile result that's older than the last applied one.

} 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<Job[]>(seedJobs);

Expand All @@ -58,13 +116,18 @@ 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,
submittedAt: new Date().toISOString(),
assertionId,
}),
);
await reconcileFromChain(setJobs, jobId, milestoneId, assertionId, signerAddress);
}, []);

const disputeMilestone = useCallback(async (jobId: string, milestoneId: string, signerAddress: string) => {
Expand All @@ -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(
Expand All @@ -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],
);
Expand All @@ -104,9 +169,25 @@ 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]);

// 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, jobId, milestoneId, milestone.assertionId, readAs);
}, []);

const value = useMemo<JobsContextValue>(
() => ({
jobs,
Expand All @@ -115,8 +196,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 <JobsContext.Provider value={value}>{children}</JobsContext.Provider>;
Expand Down
9 changes: 9 additions & 0 deletions demos/freelance-escrow/src/state/jobs-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export interface JobsContextValue {
agreesWithFreelancer: boolean,
) => Promise<void>;
finalizeMilestone: (jobId: string, milestoneId: string, callerAddress: string) => Promise<void>;
/**
* 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<void>;
}

export const JobsContext = createContext<JobsContextValue | null>(null);
Loading