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>
This commit is contained in:
FilipBl4gojevic
2026-08-16 09:50:51 +02:00
parent 019766ab8d
commit d5fdd1f1b7
3 changed files with 186 additions and 3 deletions

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

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