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 188 additions and 164 deletions

View File

@@ -19,26 +19,6 @@ import { badRequest, forbidden } from "../openai/errors.js";
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
/** Bound on per-request escrow liveness lookups; rows past the cap count as
* locked unverified (a key with this many active escrows has bigger problems). */
const LIVENESS_CHECK_CAP = 32;
/** True when the escrow UTxO behind an indexer row is still unspent. The
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
* consume the escrow without a new escrow output), so "active" rows can be
* long spent. Unparseable refs and failed lookups count as live so errors
* inflate the locked figure rather than hide funds. */
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
if (!m) return true;
try {
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
return utxo !== null;
} catch {
return true;
}
}
function ap3x(lovelace: bigint): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
@@ -94,15 +74,8 @@ export function makeAccountHandler(deps: GatewayDeps) {
if (r.ok) {
const rows = (await r.json()) as Array<Record<string, unknown>>;
if (Array.isArray(rows)) {
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
const liveness = await Promise.all(
active.map((row, i) =>
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
),
);
for (let i = 0; i < active.length; i++) {
if (!liveness[i]) continue;
const row = active[i];
for (const row of rows) {
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
lockedInEscrow +=
BigInt(String(row.payment_lovelace ?? "0")) +
BigInt(String(row.buyer_bond_lovelace ?? "0")) +

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,132 +0,0 @@
/**
* gateway-account-locked.test.ts — /account locked_in_escrow_lovelace accuracy.
*
* The indexer never updates escrow rows on terminal spends (Accept/Reclaim/
* Release consume the UTxO without producing a new escrow output), so rows
* can sit in an "active" state long after the funds moved. The account
* handler must verify each active row's UTxO is still unspent on-chain
* before counting it as locked; a spent ref is a stale row, not locked funds.
*
* Note: supplier_bond stays in the sum on purpose — the buyer fronts
* payment + buyer_bond + supplier_bond at PostEscrow (see postEscrow.ts
* totalLocked), so all three legs are the key's money while the escrow lives.
*/
import { describe, it, expect, vi } from "vitest";
import type { Request, Response } from "express";
import { makeAccountHandler } from "../../gateway/src/account/routes.js";
const LIVE_REF = "a".repeat(64) + "#0";
const STALE_REF = "b".repeat(64) + "#1";
function escrowRow(utxoRef: string, state: string) {
return {
utxo_ref: utxoRef,
state,
payment_lovelace: "2000000",
buyer_bond_lovelace: "1000000",
supplier_bond_lovelace: "1000000",
};
}
function fakeKeyRow() {
return {
id: "key-1",
key_hash: "h",
key_prefix: "vk_live_abc",
label: null,
wallet_pkh: "c".repeat(56),
deposit_address: "addr1_deposit",
enc_priv_nonce: "",
enc_priv_ct: "",
enc_priv_tag: "",
master_key_version: 1,
created_at: 0,
disabled: 0,
demo: 0,
};
}
function makeDeps(rows: Array<Record<string, unknown>>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise<unknown> }) {
const queryUtxo =
opts?.queryUtxo ??
(async (ref: { txHash: string; index: number }) => {
const canonical = `${ref.txHash}#${ref.index}`;
return canonical === LIVE_REF ? { ref, lovelace: 4_000_000n } : null;
});
return {
config: { indexerUrl: "http://indexer.test", networkId: 1, masterKeyHex: "00" },
store: {
sumCostLovelace: () => 0n,
countUsage: () => 0,
listUsage: () => [],
},
chain: {
queryUtxosByAddress: async () => [],
queryUtxo: vi.fn(queryUtxo),
},
registry: {},
fetchFn: vi.fn(async () => ({
ok: true,
json: async () => rows,
})),
} as never;
}
/**
* asyncHandler returns a fire-and-forget (req, res) => void wrapper, so the
* test must await the res.json() call itself, not the handler invocation.
* Errors also surface through json() (sendError maps them to a JSON body).
*/
async function callAccount(deps: never): Promise<Record<string, never>> {
const handler = makeAccountHandler(deps);
const req = { gatewayKey: fakeKeyRow() } as unknown as Request;
const body = await new Promise<unknown>((resolveBody, rejectBody) => {
const timer = setTimeout(() => rejectBody(new Error("handler never responded")), 2_000);
const res = {
status: (_code: number) => res,
json: (b: unknown) => {
clearTimeout(timer);
resolveBody(b);
return res;
},
} as unknown as Response;
handler(req, res);
});
if ((body as { error?: unknown }).error) {
throw new Error(`handler returned error body: ${JSON.stringify(body)}`);
}
return body as Record<string, never>;
}
describe("GET /account — locked_in_escrow_lovelace", () => {
it("counts payment + both bonds for an active escrow whose UTxO is live", async () => {
const body = await callAccount(makeDeps([escrowRow(LIVE_REF, "Open")]));
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
it("excludes an active-state row whose UTxO is terminally spent (stale indexer row)", async () => {
const body = await callAccount(
makeDeps([escrowRow(LIVE_REF, "Open"), escrowRow(STALE_REF, "Submitted")]),
);
// Only the live escrow counts: 4_000_000, not 8_000_000.
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
it("skips terminal-state rows without a chain lookup", async () => {
const deps = makeDeps([escrowRow(LIVE_REF, "Accepted"), escrowRow(STALE_REF, "Reclaimed")]);
const body = await callAccount(deps);
expect(body.balance.locked_in_escrow_lovelace).toBe("0");
expect((deps as { chain: { queryUtxo: ReturnType<typeof vi.fn> } }).chain.queryUtxo).not.toHaveBeenCalled();
});
it("counts a row conservatively when the chain lookup fails (best-effort)", async () => {
const deps = makeDeps([escrowRow(LIVE_REF, "Open")], {
queryUtxo: async () => {
throw new Error("ogmios unreachable");
},
});
const body = await callAccount(deps);
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
});
});

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);
});
});