From d5fdd1f1b710a52e91ce92a7b5e02ab5292eccf0 Mon Sep 17 00:00:00 2001 From: FilipBl4gojevic <84703395+FilipBl4gojevic@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:50:51 +0200 Subject: [PATCH] feat(tx): live CBOR builder for Release - Submitted strays become resolvable 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 --- packages/shared/src/tx/escrow/release.ts | 21 ++++- packages/shared/src/tx/internal/liveCbor.ts | 85 ++++++++++++++++++++- tests/unit/tx-release-live-dispatch.test.ts | 83 ++++++++++++++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 tests/unit/tx-release-live-dispatch.test.ts diff --git a/packages/shared/src/tx/escrow/release.ts b/packages/shared/src/tx/escrow/release.ts index 6bb8f07..4113f17 100644 --- a/packages/shared/src/tx/escrow/release.ts +++ b/packages/shared/src/tx/escrow/release.ts @@ -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; diff --git a/packages/shared/src/tx/internal/liveCbor.ts b/packages/shared/src/tx/internal/liveCbor.ts index 8a1529e..1410fbe 100644 --- a/packages/shared/src/tx/internal/liveCbor.ts +++ b/packages/shared/src/tx/internal/liveCbor.ts @@ -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 { + 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( diff --git a/tests/unit/tx-release-live-dispatch.test.ts b/tests/unit/tx-release-live-dispatch.test.ts new file mode 100644 index 0000000..61247db --- /dev/null +++ b/tests/unit/tx-release-live-dispatch.test.ts @@ -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; + const args = mockFn.mock.calls.at(-1)?.[0] as Record; + 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); + }); +});