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