Two structural fixes for demo concurrency (3 single-slot suppliers thrashed
under Open WebUI's ~3 conversations per message):
Multi-slot suppliers (MAX_CHAT_SESSIONS, default 1):
- SupplierState is now capacity-aware: a Set of active escrow refs with
free-until-full status semantics, ref-scoped release(ref) (also fixes the
latent bug where handler catch-alls released another request's slot), and
a walletMutex serializing every wallet-spending chain op — Claim, Submit,
and wallet-health consolidation queue instead of racing the single
working/collateral UTxO. /status gains active_sessions/max_sessions.
Entry-ticket settle mode (CHAT_SETTLE_MODE=ticket, default full, both sides):
- The buyer's escrow post at session open is the ONLY chain op. The supplier
verifies the Open escrow as an entry ticket but never Claims/Submits
(/v1/chat/start → {status:"ticket"}; /v1/chat/end → receipt-less
{status:"ended"}); no hard-cap timer, so sessions may outlive deliver_by.
Used tickets can't reopen sessions (records retained; 409 session_exists).
- Buyer SDK: startChat/endChat are settle-mode aware, driven by the
supplier's response — mixed-mode rollout windows settle correctly.
- Gateway: ticket closes bill cost 0; the sweeper Reclaims Open tickets
after deliver_by WITHOUT closing live sessions (the conversation and its
affinity mirror survive their reclaimed ticket), gates reclaims on
deliver_by, and keeps an in-process settled-refs memory so terminal
indexer rows aren't retried every sweep forever.
Deployment (kimi-chat trio + gateway): ticket mode, 8 slots per supplier,
idle policy gateway 10 min / supplier 15 min (fixes the inversion where the
supplier idle-closed before the gateway janitor).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
/**
|
|
* gateway/src/onchain/settle.ts — settlement + recovery helpers.
|
|
*
|
|
* submitPrompt returns the ORIGINAL Open escrow ref, but the supplier's
|
|
* Claim→Submit moves the live escrow to a NEW ref. To Accept (settle) we must
|
|
* resolve the current Submitted ref via the indexer (mirrors buyer /v1/accept:
|
|
* match the lineage by posted_at). Because the per-key mutex serializes a
|
|
* wallet's escrows, at settle time the wallet has at most one in-flight escrow,
|
|
* so a lone Submitted row is unambiguously ours.
|
|
*
|
|
* acceptResult/reclaim on the SDK are fire-and-forget; here we buildAcceptTx /
|
|
* buildReclaimTx and AWAIT confirmation so a returned success means settled.
|
|
*/
|
|
|
|
import type { ChainProvider, OutputReference } from "@marketplace/shared/chain";
|
|
import type { WalletKey } from "@marketplace/shared/tx";
|
|
import { buildAcceptTx, buildReclaimTx } from "@marketplace/shared/tx";
|
|
import { parseRef } from "../routing/selectSupplier.js";
|
|
import { GatewayError } from "../openai/errors.js";
|
|
|
|
export interface EscrowRow {
|
|
utxo_ref: string;
|
|
state: string;
|
|
posted_at: number;
|
|
/** Validator reclaim floor (indexer serves it; absent on very old rows). */
|
|
deliver_by?: number;
|
|
}
|
|
|
|
export async function fetchEscrows(
|
|
indexerUrl: string,
|
|
buyerPkh: string,
|
|
fetchFn: typeof globalThis.fetch,
|
|
): Promise<EscrowRow[]> {
|
|
const res = await fetchFn(`${indexerUrl}/escrows?buyer=${buyerPkh}`);
|
|
if (!res.ok) throw new Error(`indexer /escrows returned ${res.status}`);
|
|
const body = (await res.json()) as unknown;
|
|
if (!Array.isArray(body)) throw new Error("indexer /escrows did not return an array");
|
|
return body as EscrowRow[];
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
/** Poll the indexer for the current Submitted UTxO of our in-flight escrow. */
|
|
export async function resolveSubmittedRef(opts: {
|
|
indexerUrl: string;
|
|
buyerPkh: string;
|
|
originalRefStr: string;
|
|
fetchFn?: typeof globalThis.fetch;
|
|
timeoutMs?: number;
|
|
intervalMs?: number;
|
|
}): Promise<OutputReference> {
|
|
const fetchFn = opts.fetchFn ?? globalThis.fetch;
|
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
const intervalMs = opts.intervalMs ?? 2_000;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
let lastErr: unknown;
|
|
for (;;) {
|
|
try {
|
|
const rows = await fetchEscrows(opts.indexerUrl, opts.buyerPkh, fetchFn);
|
|
|
|
// 1. Direct: the original ref already shows Submitted.
|
|
const direct = rows.find((r) => r.utxo_ref === opts.originalRefStr && r.state === "Submitted");
|
|
if (direct) return refOrThrow(direct.utxo_ref);
|
|
|
|
// 2. Lineage: find the original row to read posted_at, then the Submitted
|
|
// row sharing it (the validator preserves posted_at across states).
|
|
const lineage = rows.find((r) => r.utxo_ref === opts.originalRefStr);
|
|
if (lineage) {
|
|
const submitted = rows.find((r) => r.posted_at === lineage.posted_at && r.state === "Submitted");
|
|
if (submitted) return refOrThrow(submitted.utxo_ref);
|
|
}
|
|
|
|
// 3. Fallback: the mutex guarantees ≤1 in-flight escrow for this wallet,
|
|
// so a single Submitted row is ours.
|
|
const submittedRows = rows.filter((r) => r.state === "Submitted");
|
|
if (submittedRows.length === 1) return refOrThrow(submittedRows[0].utxo_ref);
|
|
} catch (err) {
|
|
lastErr = err;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
throw new GatewayError(
|
|
502,
|
|
"server_error",
|
|
"escrow_settle_failed",
|
|
`could not resolve Submitted escrow within ${timeoutMs}ms${lastErr ? `: ${String(lastErr)}` : ""}`,
|
|
);
|
|
}
|
|
await sleep(intervalMs);
|
|
}
|
|
}
|
|
|
|
function refOrThrow(refStr: string): OutputReference {
|
|
const ref = parseRef(refStr);
|
|
if (!ref) {
|
|
throw new GatewayError(502, "server_error", "escrow_settle_failed", `bad escrow ref ${refStr}`);
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
/** Build + submit + await the Accept tx (Submitted → Accepted, terminal). */
|
|
export async function acceptAndConfirm(
|
|
chain: ChainProvider,
|
|
walletKey: WalletKey,
|
|
escrowRef: OutputReference,
|
|
awaitTimeoutMs = 120_000,
|
|
): Promise<string> {
|
|
const built = await buildAcceptTx({ chain, buyerKey: walletKey, escrowRef });
|
|
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
|
|
return built.expectedTxHash;
|
|
}
|
|
|
|
/** Build + submit + await a Reclaim tx (Open/Claimed → Reclaimed). */
|
|
export async function reclaimAndConfirm(
|
|
chain: ChainProvider,
|
|
walletKey: WalletKey,
|
|
escrowRef: OutputReference,
|
|
awaitTimeoutMs = 120_000,
|
|
): Promise<string> {
|
|
const built = await buildReclaimTx({ chain, buyerKey: walletKey, escrowRef });
|
|
await chain.awaitTx(built.expectedTxHash, awaitTimeoutMs);
|
|
return built.expectedTxHash;
|
|
}
|