1 Commits

Author SHA1 Message Date
FilipBl4gojevic
d5fdd1f1b7 feat(tx): live CBOR builder for Release - Submitted strays become resolvable
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Release was the one escrow spend path still hardwired to the synthetic
testTxBody backend, so suppliers had no way to resolve Submitted escrows
past the accept window on mainnet (the sweeper parks them 'for supplier
Release'). Adds buildLiveTxForRelease mirroring the Reclaim builder
(supplier payout, Constr4/tag 125 redeemer, Ogmios UPLC eval) and the
detectCborBackend dispatch in buildReleaseTx, reclaim.ts-style.

validFrom clamps to submitted_at + ACCEPT_WINDOW rather than tip-60s
alone: the back-pad by itself violates the validator whenever the tip is
less than 60s past the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:50:51 +02:00
5 changed files with 193 additions and 200 deletions

View File

@@ -15,88 +15,19 @@ import { open as unseal } from "../crypto/seal.js";
import { deriveWalletKey } from "../wallet.js";
import type { ApiKeyRow } from "../db/store.js";
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
* anything past this is the 2026-08-12 freeze mode (a fetch that never
* settles), not a slow success. */
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
* a queue this deep means the head is stuck and callers should fail fast. */
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
export class MutexTimeoutError extends Error {
constructor(label: string, timeoutMs: number) {
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
this.name = "MutexTimeoutError";
}
}
/** run() was refused because the queue is at its bound (head likely stuck). */
export class MutexQueueFullError extends Error {
constructor(label: string, depth: number) {
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
this.name = "MutexQueueFullError";
}
}
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
// Node-only nicety: don't hold the process open for a pending deadline.
timer.unref?.();
fn().then(
(v) => {
clearTimeout(timer);
resolvePromise(v);
},
(e) => {
clearTimeout(timer);
rejectPromise(e);
},
);
});
}
/**
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
*
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
* A zombie that settles later can at worst lose a UTxO-contention race with
* the run that replaced it — strictly better than the alternative, where one
* stuck chain call convoys every subsequent request on the key forever.
*/
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
export class Mutex {
private tail: Promise<unknown> = Promise.resolve();
private depth = 0;
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
if (this.depth >= maxQueue) {
return Promise.reject(new MutexQueueFullError(label, this.depth));
}
this.depth++;
// Keep the chain alive even if fn rejects or times out; swallow here so
// the next run() isn't poisoned by a prior rejection.
const result = this.tail.then(
() => runWithDeadline(fn, timeoutMs, label),
() => runWithDeadline(fn, timeoutMs, label),
);
const settled = result.finally(() => {
this.depth--;
});
this.tail = settled.then(
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(fn, fn);
// Keep the chain alive even if fn rejects; swallow here so the next
// run() isn't poisoned by a prior rejection.
this.tail = result.then(
() => undefined,
() => undefined,
);
return settled;
return result;
}
}

View File

@@ -17,6 +17,8 @@ import type { WalletKey, BuildResult } from "../types.js";
import { TxConstructionError } from "../types.js";
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
import { ACCEPT_WINDOW_MS, mockSlotToWallclockMs } from "../internal/constants.js";
import { detectCborBackend } from "../internal/cborBackend.js";
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
export interface ReleaseParams {
chain: ChainProvider;
@@ -65,8 +67,10 @@ export async function buildReleaseTx(
}
// 4. Tip must be at or past submitted_at + ACCEPT_WINDOW.
// Live backend uses real POSIX ms; mock backend keeps slot*1000.
const tipSlot = await chain.tip();
const tipMs = mockSlotToWallclockMs(tipSlot);
const isLive = detectCborBackend(chain) === "live";
const tipMs = isLive ? Date.now() : mockSlotToWallclockMs(tipSlot);
const threshold = datum.submitted_at + ACCEPT_WINDOW_MS;
if (tipMs < threshold) {
throw new TxConstructionError(
@@ -75,6 +79,21 @@ export async function buildReleaseTx(
);
}
// Live path: real Cardano CBOR via lucid-evolution.
if (isLive) {
const liveCborPath = "../internal/liveCbor.js";
const { buildLiveTxForRelease } = await import(/* @vite-ignore */ liveCborPath);
return buildLiveTxForRelease({
chain: chain as LiveOgmiosProvider,
supplierKey,
escrowRef,
escrowUtxo: utxo,
datum,
tipMs,
});
}
// Mock path: synthetic testTxBody (preserves existing test behaviour).
const supplierDue =
datum.payment_lovelace + datum.supplier_bond_lovelace + datum.buyer_bond_lovelace;

View File

@@ -10,8 +10,8 @@
* for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid
* does not validate address network for inputs (only for outputs).
*
* Deferred builders (postAdvert, updateAdvert, retireAdvert, reclaim, release)
* are NOT touched in M1-F-4. Those continue using the synthetic testTxBody path.
* Deferred builders: only updateAdvert remains on the synthetic testTxBody
* path. postAdvert, retireAdvert, reclaim, and release all have live builders.
*
* Catherine M1-F-4-green.
*/
@@ -61,6 +61,7 @@ const REDEEMER_CLAIM = 121;
const REDEEMER_SUBMIT = 122;
const REDEEMER_ACCEPT = 123;
const REDEEMER_RECLAIM = 124;
const REDEEMER_RELEASE = 125;
// AdvertRedeemer Constr indices per lib/marketplace/types.ak:
// PostAdvert → Constr0 (tag 121, creation event — never seen by spend)
@@ -121,6 +122,15 @@ export interface LiveReclaimParams {
tipMs: number;
}
export interface LiveReleaseParams {
chain: LiveOgmiosProvider;
supplierKey: WalletKey;
escrowRef: OutputReference;
escrowUtxo: Utxo;
datum: EscrowDatum;
tipMs: number;
}
export interface LivePostAdvertParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
@@ -778,6 +788,77 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
return { txCborHex, expectedTxHash };
}
// ─── Release ─────────────────────────────────────────────────────────
export async function buildLiveTxForRelease(params: LiveReleaseParams): Promise<BuildResult> {
const provider = buildLucidProvider(params.chain);
const { lucid } = await createLucidContext(provider, params.supplierKey, {
networkId: 1,
systemStartUnix: 0,
slotLengthMs: 1000,
}, { usePresetProtocolParameters: true });
const { script, address: escrowAddress } = loadEscrowScript();
const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script);
const lucidInput = chainUtxoToLucidScriptInput(params.escrowUtxo, escrowAddress);
const releaseRedeemerHex = encodeNullaryConstrHex(REDEEMER_RELEASE);
if (params.datum.submitted_at === null) {
throw new TxConstructionError(
"submitted_at missing",
"Submitted-state escrow must carry a submitted_at timestamp",
);
}
// Release pays everything to the supplier: payment + supplier_bond + buyer_bond.
const supplierDue =
params.datum.payment_lovelace +
params.datum.supplier_bond_lovelace +
params.datum.buyer_bond_lovelace;
const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue;
const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID);
const realWalletUtxos = await lucid.wallet().getUtxos();
assertCollateralCandidate(realWalletUtxos);
const presetWalletInputs = realWalletUtxos;
// escrow.ak:handle_release checks `lower_bound >= submitted_at + ACCEPT_WINDOW`.
// Clamp validFrom to that threshold: the Reclaim-style tip-60s back-pad alone
// would violate the validator whenever the tip is less than 60s past the window.
const threshold = params.datum.submitted_at + ACCEPT_WINDOW_MS;
const validFromMs = Math.max(threshold, params.tipMs - 60_000);
let signed;
try {
const txBuilder = (
spendCtx.kind === "ref"
? lucid.newTx().readFrom([spendCtx.refUtxo])
: lucid.newTx().attach.SpendingValidator(spendCtx.script)
)
.collectFrom([lucidInput], releaseRedeemerHex)
.pay.ToAddress(supplierAddress, { lovelace: supplierLovelace })
.addSignerKey(params.supplierKey.pubKeyHash)
.validFrom(validFromMs)
.validTo(params.tipMs + 60_000)
.setMinFee(500_000n);
// Same UPLC eval delegation as Accept/Reclaim — lucid's CML evaluator
// falsely rejects valid Plutus V3 spends; route to Ogmios.
const completed = await txBuilder.complete({
presetWalletInputs,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (err) {
rethrowAsTxError(err, "release build failed");
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await params.chain.submitTx(txCborHex);
return { txCborHex, expectedTxHash };
}
// ─── PostAdvert ──────────────────────────────────────────────────────
export async function buildLiveTxForAdvert(

View File

@@ -1,121 +0,0 @@
/**
* gateway-mutex.test.ts — Mutex deadline + queue bound (2026-08-12 freeze fix).
*
* Incident: one chain call that never settled, run under a per-key Mutex with
* no deadline, convoyed every subsequent request on that key silently while
* /models and /account stayed healthy. The fix gives run() a deadline that
* abandons the stuck operation (advancing the chain) and a queue bound that
* rejects new work instead of queueing without limit.
*
* Serialization semantics of the original class must be preserved.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
Mutex,
MutexTimeoutError,
MutexQueueFullError,
} from "../../gateway/src/sdk/registry.js";
afterEach(() => {
vi.useRealTimers();
});
// ─── Preserved semantics ─────────────────────────────────────────────────────
describe("Mutex — serialization (preserved semantics)", () => {
it("runs fns strictly one at a time, in order", async () => {
const mutex = new Mutex();
const order: number[] = [];
let firstRunning = false;
const first = mutex.run(async () => {
firstRunning = true;
await new Promise((r) => setTimeout(r, 20));
order.push(1);
firstRunning = false;
});
const second = mutex.run(async () => {
expect(firstRunning).toBe(false);
order.push(2);
});
await Promise.all([first, second]);
expect(order).toEqual([1, 2]);
});
it("a rejecting fn does not poison the chain", async () => {
const mutex = new Mutex();
await expect(mutex.run(async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
await expect(mutex.run(async () => "alive")).resolves.toBe("alive");
});
});
// ─── Deadline (the freeze fix) ───────────────────────────────────────────────
describe("Mutex — per-run deadline", () => {
it("rejects a stuck run with MutexTimeoutError once the deadline passes", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck-chain-read");
// Attach the rejection expectation BEFORE advancing time so the rejection
// is not treated as unhandled.
const assertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
await vi.advanceTimersByTimeAsync(1_001);
await assertion;
});
it("queued work proceeds after a stuck run is abandoned (no convoy)", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck");
const stuckAssertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
const queued = mutex.run(async () => "unblocked");
await vi.advanceTimersByTimeAsync(1_001);
await stuckAssertion;
await expect(queued).resolves.toBe("unblocked");
});
it("a run that finishes in time is unaffected by the deadline", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const quick = mutex.run(async () => "done");
await vi.advanceTimersByTimeAsync(1);
await expect(quick).resolves.toBe("done");
});
});
// ─── Queue bound ─────────────────────────────────────────────────────────────
describe("Mutex — queue bound", () => {
it("rejects immediately with MutexQueueFullError when the queue is full", async () => {
const mutex = new Mutex({ maxQueue: 2 });
let releaseFirst!: () => void;
const gate = new Promise<void>((r) => {
releaseFirst = r;
});
const first = mutex.run(() => gate.then(() => "first"));
const second = mutex.run(async () => "second");
const third = mutex.run(async () => "third");
await expect(third).rejects.toBeInstanceOf(MutexQueueFullError);
releaseFirst();
await expect(first).resolves.toBe("first");
await expect(second).resolves.toBe("second");
});
it("slots free up once runs settle", async () => {
const mutex = new Mutex({ maxQueue: 1 });
await mutex.run(async () => "a");
await expect(mutex.run(async () => "b")).resolves.toBe("b");
});
});

View File

@@ -0,0 +1,83 @@
/**
* tx-release-live-dispatch.test.ts — buildReleaseTx() must route to the live
* CBOR builder when the chain is a LiveOgmiosProvider, mirroring reclaim.ts.
*
* Parity gap under test: Release is the one escrow spend path still hardwired
* to the synthetic testTxBody backend, which leaves Submitted-past-accept-window
* escrows unresolvable on mainnet (sweeper parks them "for supplier Release").
*
* The live builder itself needs a real wallet + Ogmios and is exercised by the
* mainnet fleet scripts like the other six builders; here it is mocked at the
* module boundary and only the dispatch contract is asserted.
*/
import { describe, it, expect, vi } from "vitest";
vi.mock("../../packages/shared/src/tx/internal/liveCbor.js", () => ({
buildLiveTxForRelease: vi.fn(async () => ({
txCborHex: "live-cbor-sentinel",
expectedTxHash: "live-hash-sentinel",
})),
}));
import { buildReleaseTx } from "../../packages/shared/src/tx/escrow/release.js";
import { LiveOgmiosProvider } from "../../packages/shared/src/chain/LiveOgmiosProvider.js";
import { ACCEPT_WINDOW_MS } from "../../packages/shared/src/tx/escrow/accept.js";
import * as liveCbor from "../../packages/shared/src/tx/internal/liveCbor.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
import {
buildSubmittedEscrowUtxo,
SUBMITTED_AT,
} from "../fixtures/buyer-side/sample-escrow-utxos.js";
const RELEASE_THRESHOLD = SUBMITTED_AT + ACCEPT_WINDOW_MS;
/**
* A stub that passes the `instanceof LiveOgmiosProvider` backend check without
* opening any connection. Only the methods buildReleaseTx touches pre-dispatch
* are provided. Tip slot is set past the accept window so that, while the
* dispatch gap exists, the mock path completes and returns synthetic CBOR
* (a clean assertion failure) instead of erroring on the window check.
*/
function fakeLiveChain(): LiveOgmiosProvider {
const utxo = buildSubmittedEscrowUtxo();
const fake = Object.create(LiveOgmiosProvider.prototype) as LiveOgmiosProvider;
Object.assign(fake, {
queryUtxo: async () => utxo,
tip: async () => Math.floor((RELEASE_THRESHOLD + 60_000) / 1000),
submitTx: async () => undefined,
});
return fake;
}
describe("buildReleaseTx() — live backend dispatch", () => {
it("routes to buildLiveTxForRelease when chain is a LiveOgmiosProvider", async () => {
const chain = fakeLiveChain();
const supplier = buildSupplierWalletKey();
const utxo = buildSubmittedEscrowUtxo();
const result = await buildReleaseTx({ chain, supplierKey: supplier, escrowRef: utxo.ref });
expect(result.txCborHex).toBe("live-cbor-sentinel");
expect(result.expectedTxHash).toBe("live-hash-sentinel");
expect(liveCbor.buildLiveTxForRelease).toHaveBeenCalledTimes(1);
});
it("passes the escrow UTxO, decoded datum, and a real-clock tipMs to the live builder", async () => {
const chain = fakeLiveChain();
const supplier = buildSupplierWalletKey();
const utxo = buildSubmittedEscrowUtxo();
const before = Date.now();
await buildReleaseTx({ chain, supplierKey: supplier, escrowRef: utxo.ref });
const mockFn = liveCbor.buildLiveTxForRelease as unknown as ReturnType<typeof vi.fn>;
const args = mockFn.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(args.escrowUtxo).toEqual(utxo);
expect((args.datum as { state: string }).state).toBe("Submitted");
expect(args.supplierKey).toBe(supplier);
// Live path derives tipMs from the real clock (reclaim.ts parity), not
// from the mock slot→ms mapping.
expect(args.tipMs as number).toBeGreaterThanOrEqual(before);
});
});