/** * tx/internal/liveCbor.ts — lucid-evolution CBOR helpers for the 4 in-scope builders. * * Each function here is called by the corresponding builder when the chain * provider is a LiveOgmiosProvider. Pre-chain validation has already run in * the caller; we receive validated parameters and produce real Cardano CBOR. * * Network: Mainnet (per architecture decision documented in lucid-context.test.ts). * Output addresses use mainnet header bytes (0x71 for script enterprise, 0x61 * for vkh enterprise). Input UTxOs may carry testnet-prefixed addresses — lucid * does not validate address network for inputs (only for outputs). * * Deferred builders: only updateAdvert remains on the synthetic testTxBody * path. postAdvert, retireAdvert, reclaim, and release all have live builders. * * Catherine M1-F-4-green. */ import { readFileSync } from "fs"; import { dirname, resolve } from "path"; import { fileURLToPath } from "url"; import type { LucidEvolution, UTxO as LucidUTxO, Script } from "@lucid-evolution/lucid"; import type { OutputReference, Utxo } from "../../chain/ChainProvider.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; import type { AdvertDatum, EscrowDatum } from "../../cbor/types.js"; import type { ChatMessage, BuildResult, PostAdvertBuildResult, PostEscrowBuildResult, WalletKey, } from "../types.js"; import { TxConstructionError } from "../types.js"; import { encodeAdvertDatum } from "../../cbor/AdvertDatum.js"; import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js"; import { plutusTag } from "../../cbor/plutus-tag.js"; import { encodePlutus } from "../../cbor/plutus-encoder.js"; import { loadBlueprint } from "../blueprint.js"; import { pkhToEnterpriseAddress } from "./pkhAddress.js"; import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js"; import { createLucidContext } from "./lucidContext.js"; import { ACCEPT_WINDOW_MS } from "./constants.js"; // Mainnet network id used for all output addresses produced by the live path. const MAINNET_ID: 0 | 1 = 1; // Min UTxO floor for outputs (lovelace). 2 ADA matches the test's expectation. const MIN_UTXO_LOVELACE = 2_000_000n; // Redeemer Constr indices per contracts/marketplace/validators/escrow.ak + // lib/marketplace/types.ak EscrowRedeemer: // Claim → Constr0 (tag 121, nullary) // Submit { receipt_hash } → Constr1 (tag 122, one ByteArray field) // Accept → Constr2 (tag 123, nullary) // Reclaim → Constr3 (tag 124, nullary) // Release → Constr4 (tag 125, nullary) 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) // UpdateAdvert → Constr1 (tag 122, nullary) // RetireAdvert → Constr2 (tag 123, nullary) const REDEEMER_RETIRE_ADVERT = 123; // ─── Param types (legacy stub shape — preserved for compatibility) ──────────── export interface LivePostEscrowParams { chain: LiveOgmiosProvider; buyerKey: WalletKey; advertRef: OutputReference; messages: ChatMessage[]; escrowDatum: EscrowDatum; totalLocked: bigint; deliverBy: number; postedAt: number; } export interface LiveClaimParams { chain: LiveOgmiosProvider; supplierKey: WalletKey; escrowRef: OutputReference; escrowUtxo: Utxo; newDatum: EscrowDatum; deliverBy: number; tipMs: number; } export interface LiveSubmitParams { chain: LiveOgmiosProvider; supplierKey: WalletKey; escrowRef: OutputReference; escrowUtxo: Utxo; newDatum: EscrowDatum; deliverBy: number; tipMs: number; receiptHash: string; beforeSubmit?: (built: BuildResult) => Promise; } export interface LiveAcceptParams { chain: LiveOgmiosProvider; buyerKey: WalletKey; escrowRef: OutputReference; escrowUtxo: Utxo; datum: EscrowDatum; tipMs: number; windowEnd: number; } export interface LiveReclaimParams { chain: LiveOgmiosProvider; buyerKey: WalletKey; escrowRef: OutputReference; escrowUtxo: Utxo; datum: EscrowDatum; tipMs: number; } export interface LiveReleaseParams { chain: LiveOgmiosProvider; supplierKey: WalletKey; escrowRef: OutputReference; escrowUtxo: Utxo; datum: EscrowDatum; tipMs: number; } export interface LivePostAdvertParams { chain: LiveOgmiosProvider; walletKey: WalletKey; advertDatum: AdvertDatum; deposit_lovelace: bigint; beforeSubmit?: (built: PostAdvertBuildResult) => Promise; } export interface LiveRetireAdvertParams { chain: LiveOgmiosProvider; walletKey: WalletKey; advertRef: OutputReference; advertUtxo: Utxo; datum: AdvertDatum; } // ─── Helpers ───────────────────────────────────────────────────────── function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) { throw new Error(`invalid hex (odd length): ${hex.length}`); } const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { out[i / 2] = parseInt(hex.substring(i, i + 2), 16); } return out; } /** Encode a nullary Plutus Constr (no fields) as hex CBOR. */ function encodeNullaryConstrHex(tag: number): string { return bytesToHex(encodePlutus(plutusTag(tag, []))); } /** Construct an OgmiosLucidProvider that shares the chain's Ogmios endpoint * and the chain's injected fetch (so test mocks see lucid's traffic). */ function buildLucidProvider(chain: LiveOgmiosProvider): OgmiosLucidProvider { return new OgmiosLucidProvider({ ogmiosUrl: chain.url, fetch: chain.fetchImpl, }); } /** Load the escrow spending validator script bytes from the blueprint. * Exported so the publish-reference-scripts CLI can publish the same bytes * under a CIP-33 scriptRef without duplicating plutus.json reading. */ export function loadEscrowScript(): { script: Script; address: string } { const blueprint = loadBlueprint(); // Read plutus.json directly to grab compiledCode (the blueprint loader only // exposes the hash). We piggyback on the same path resolution. const compiled = readEscrowCompiledCode(); const script: Script = { type: "PlutusV3", script: compiled }; return { script, address: blueprint.escrowScriptAddress(MAINNET_ID) }; } let cachedCompiledCode: string | undefined; function readEscrowCompiledCode(): string { if (cachedCompiledCode) return cachedCompiledCode; const here = dirname(fileURLToPath(import.meta.url)); const filePath = resolve( here, "..", "..", "..", "..", "..", "contracts", "marketplace", "plutus.json", ); const raw = readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as { validators: Array<{ title: string; compiledCode?: string }>; }; for (const v of parsed.validators) { if (typeof v.title === "string" && v.title.startsWith("escrow.") && typeof v.compiledCode === "string") { cachedCompiledCode = v.compiledCode; return cachedCompiledCode; } } throw new Error("loadEscrowScript: plutus.json missing escrow validator compiledCode"); } /** Load the advert spending validator script bytes from the blueprint. * Exported for the same reason as loadEscrowScript above. */ export function loadAdvertScript(): { script: Script; address: string } { const blueprint = loadBlueprint(); const compiled = readAdvertCompiledCode(); const script: Script = { type: "PlutusV3", script: compiled }; return { script, address: blueprint.advertScriptAddress(MAINNET_ID) }; } let cachedAdvertCompiledCode: string | undefined; function readAdvertCompiledCode(): string { if (cachedAdvertCompiledCode) return cachedAdvertCompiledCode; const here = dirname(fileURLToPath(import.meta.url)); const filePath = resolve( here, "..", "..", "..", "..", "..", "contracts", "marketplace", "plutus.json", ); const raw = readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw) as { validators: Array<{ title: string; compiledCode?: string }>; }; for (const v of parsed.validators) { if (typeof v.title === "string" && v.title.startsWith("advert.") && typeof v.compiledCode === "string") { cachedAdvertCompiledCode = v.compiledCode; return cachedAdvertCompiledCode; } } throw new Error("loadAdvertScript: plutus.json missing advert validator compiledCode"); } /** Parse a `#` env var into an OutRef. Returns null if unset. */ function parseRefUtxoEnv(envKey: string): { txHash: string; outputIndex: number } | null { const v = process.env[envKey]; if (!v) return null; const [txHash, idxStr] = v.split("#"); if (!txHash || !idxStr) { throw new Error(`${envKey} must be #, got: ${v}`); } const outputIndex = Number(idxStr); if (!Number.isInteger(outputIndex) || outputIndex < 0) { throw new Error(`${envKey} index must be a non-negative integer, got: ${idxStr}`); } return { txHash, outputIndex }; } /** Resolve script-witness mode for a spending tx. * - If `` is set, fetch the referenced UTxO and use CIP-33 reference-script mode. * The caller threads the UTxO into `tx.readFrom([refUtxo])`; lucid-evolution 0.4.30 * auto-extracts `refUtxo.scriptRef` and registers it as a witness * (dist/index.js:1903–1913). No `attach.SpendingValidator` call is needed. * - Otherwise, fall back to the inlined script (current behavior). */ async function resolveSpendCtx( lucid: LucidEvolution, envKey: "ESCROW_REF_UTXO" | "ADVERT_REF_UTXO", inlineScript: Script, ): Promise<{ kind: "ref"; refUtxo: LucidUTxO } | { kind: "inline"; script: Script }> { const refSpec = parseRefUtxoEnv(envKey); if (!refSpec) return { kind: "inline", script: inlineScript }; const [refUtxo] = await lucid.utxosByOutRef([refSpec]); if (!refUtxo) { throw new Error( `${envKey} ${refSpec.txHash}#${refSpec.outputIndex} not found on chain`, ); } if (!refUtxo.scriptRef) { throw new Error( `${envKey} ${refSpec.txHash}#${refSpec.outputIndex} has no scriptRef attached`, ); } return { kind: "ref", refUtxo }; } /** Build a lucid-shaped UTxO from our chain Utxo, swapping in the live * blueprint's mainnet-flavoured script address so output validation passes * on Mainnet network even when the test fixture used a testnet header. */ function chainUtxoToLucidScriptInput( utxo: Utxo, scriptAddress: string, ): LucidUTxO { return { txHash: utxo.ref.txHash, outputIndex: utxo.ref.index, address: scriptAddress, assets: { lovelace: utxo.lovelace, ...utxo.assets }, datumHash: null, datum: utxo.datumHex ?? null, scriptRef: null, }; } /** Lucid 0.4.30's default `setCollateral` is 5_000_000n; its collateral * selector needs a pure-AP3X UTxO ≥ this threshold. Mirror that floor * here so we can fail fast with an actionable hint instead of waiting * for lucid's deep "wallet must hold a pure-ADA UTxO ≥ 5 ADA" error. */ const COLLATERAL_MIN_LOVELACE = 5_000_000n; /** Pre-check: assert the wallet has a UTxO that can serve as collateral. * Called at the top of each script-spend builder so a fragmented wallet * surfaces a clear "run tx:consolidate-wallet" message instead of the * deeper lucid error caught downstream by rethrowAsTxError. */ export function assertCollateralCandidate(utxos: LucidUTxO[]): void { for (const u of utxos) { if (u.assets.lovelace < COLLATERAL_MIN_LOVELACE) continue; const onlyLovelace = Object.keys(u.assets).every((k) => k === "lovelace"); if (onlyLovelace) return; } const maxLovelace = utxos.reduce( (m, u) => (u.assets.lovelace > m ? u.assets.lovelace : m), 0n, ); throw new TxConstructionError( "collateral_required", `wallet has no UTxO ≥ ${COLLATERAL_MIN_LOVELACE} lovelace for collateral ` + `(have ${utxos.length} UTxO(s), max ${maxLovelace} lovelace). ` + `run \`pnpm --filter @marketplace/{supplier,buyer} tx:consolidate-wallet\` to re-shape the wallet.`, ); } /** TxConstructionError wrapping arbitrary lucid failures, surfacing * collateral mismatches with a stable substring so tests can assert. * * Set LIVECBOR_DEBUG=1 to see the full inner error + WASM stack — useful * for diagnosing lucid 0.4.30 / CML edge cases (e.g. the set_exunits * panic that affects test fixtures but not production). */ function rethrowAsTxError(err: unknown, fallback: string): never { const msg = err instanceof Error ? err.message : String(err); if (process.env.LIVECBOR_DEBUG) { // eslint-disable-next-line no-console console.error("[liveCbor] inner error:", err); if (err instanceof Error && err.stack) { // eslint-disable-next-line no-console console.error("[liveCbor] stack:", err.stack); } if (err instanceof Error && "cause" in err) { // eslint-disable-next-line no-console console.error("[liveCbor] cause:", (err as Error & { cause?: unknown }).cause); } } const lower = msg.toLowerCase(); if ( lower.includes("collateral") || lower.includes("not enough ada") || lower.includes("not enough funds") || lower.includes("insufficient") || lower.includes("empty_utxo") || lower.includes("missing_wallet") || lower.includes("required assets") ) { throw new TxConstructionError( "collateral required: wallet must hold a pure-ADA UTxO ≥ 5 ADA", msg, ); } throw new TxConstructionError(fallback, msg); } /** Choose PostEscrow inputs that PRESERVE a pure-ADA collateral UTxO for the * later Accept/Reclaim script-spend. PostEscrow is NOT a script spend, so lucid * is otherwise free to consume the dedicated ≥5-ADA collateral UTxO as an * ordinary input — leaving the wallet with no collateral candidate and * stranding the escrow when Accept later runs (the fragmentation bug observed * on back-to-back requests). * * Reserve the SMALLEST pure-ADA UTxO ≥ collateral floor and fund the escrow * from the remaining UTxOs. Returns null (→ default lucid coin selection) when * reservation isn't possible or needed: * - a single UTxO (nothing to reserve), * - no qualifying pure-ADA collateral UTxO, or * - excluding it would underfund the escrow. The large-single-UTxO case is * safe under default selection anyway: the change output is itself a * pure-ADA UTxO well above the collateral floor. */ export function reserveCollateralInputs( utxos: LucidUTxO[], lockedLovelace: bigint, ): LucidUTxO[] | null { if (utxos.length < 2) return null; const candidates = utxos .filter( (u) => u.assets.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).every((k) => k === "lovelace"), ) .sort((a, b) => (a.assets.lovelace < b.assets.lovelace ? -1 : 1)); if (candidates.length === 0) return null; const reserved = candidates[0]; const remaining = utxos.filter( (u) => !(u.txHash === reserved.txHash && u.outputIndex === reserved.outputIndex), ); if (remaining.length === 0) return null; const remainingLovelace = remaining.reduce((s, u) => s + u.assets.lovelace, 0n); // Cover the locked output + tx fee + a min-change cushion; else fall back. if (remainingLovelace < lockedLovelace + 2_000_000n) return null; return remaining; } // ─── PostEscrow ────────────────────────────────────────────────────── export async function buildLiveTxForEscrow( params: LivePostEscrowParams, ): Promise { const { chain, buyerKey, escrowDatum, totalLocked, deliverBy, postedAt } = params; void params.advertRef; void params.messages; const provider = buildLucidProvider(chain); // networkParams is not consumed by lucid (lucid uses its own SLOT_CONFIG_NETWORK // for "Mainnet"). We pass a placeholder so the LucidContext type stays uniform. const { lucid } = await createLucidContext(provider, buyerKey, { networkId: 1, systemStartUnix: 0, slotLengthMs: 1000, }, { usePresetProtocolParameters: true }); const blueprint = loadBlueprint(); const escrowAddress = blueprint.escrowScriptAddress(MAINNET_ID); const datumHex = encodeEscrowDatum(escrowDatum); const lockedLovelace = totalLocked < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : totalLocked; // Preserve a pure-ADA collateral UTxO so the buyer's later Accept/Reclaim // script-spend can find collateral. Without this, PostEscrow coin selection // can consume the 5-ADA collateral UTxO and strand the escrow at Accept. const walletUtxos = await lucid.wallet().getUtxos(); const reservedInputs = reserveCollateralInputs(walletUtxos, lockedLovelace); let signed; try { const txBuilder = lucid .newTx() .pay.ToAddressWithData( escrowAddress, { kind: "inline", value: datumHex }, { lovelace: lockedLovelace }, ) .addSignerKey(buyerKey.pubKeyHash) // -60s past buffer: lucid's slotFromUnixTime rounds UP, so passing // `Date.now()` produces a slot 1 ahead of the chain's current slot, // and Ogmios rejects the tx with code 3118 ("submitted too early") // until the chain catches up. Same fix as Claim/Submit/Accept paths // below — keep the buffer wide enough to absorb build+propagation. .validFrom(postedAt - 60_000) .validTo(deliverBy) // lucid 0.4.30 under-estimates Conway fees by ~1K lovelace on // PostEscrow (Ogmios 3122). 500K is a comfortable floor; excess // becomes change. Same fix as Claim/Submit/Accept paths. .setMinFee(500_000n); // When we can reserve collateral, restrict coin selection to the remaining // (working) UTxOs so the collateral candidate survives for Accept. Else // fall back to lucid's default selection (single/large-UTxO cases are safe). const completed = reservedInputs ? await txBuilder.complete({ presetWalletInputs: reservedInputs, localUPLCEval: false }) : await txBuilder.complete(); signed = await completed.sign.withWallet().complete(); } catch (err) { rethrowAsTxError(err, "post-escrow build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); await chain.submitTx(txCborHex); // Note: awaiting on-chain confirmation is the SDK's responsibility // (Marketplace.submitPrompt awaits before calling the supplier). Keeping // the live builder submit-only matches the Claim/Submit/Accept pattern // and lets unit tests exercise the builder without stubbing awaitTx. return { txCborHex, expectedTxHash, escrowOutputRef: { txHash: expectedTxHash, index: 0 }, }; } // ─── Claim / Submit / Accept share input-collection scaffolding ───────── async function buildSpendOpenOrClaimedTx(opts: { chain: LiveOgmiosProvider; walletKey: WalletKey; escrowUtxo: Utxo; newDatum: EscrowDatum; /** Pre-encoded redeemer hex. Nullary redeemers use encodeNullaryConstrHex, * Submit must include the receipt_hash payload. */ redeemerHex: string; validFromMs: number; validToMs: number; signerPkh: string; beforeSubmit?: (built: BuildResult) => Promise; }): Promise { const provider = buildLucidProvider(opts.chain); const { lucid } = await createLucidContext(provider, opts.walletKey, { networkId: 1, systemStartUnix: 0, slotLengthMs: 1000, }, { usePresetProtocolParameters: true }); const { script, address: escrowAddress } = loadEscrowScript(); const spendCtx = await resolveSpendCtx(lucid, "ESCROW_REF_UTXO", script); const datumHex = encodeEscrowDatum(opts.newDatum); const redeemerHex = opts.redeemerHex; const lucidInput = chainUtxoToLucidScriptInput(opts.escrowUtxo, escrowAddress); const lockedLovelace = opts.escrowUtxo.lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : opts.escrowUtxo.lovelace; // Augment wallet inputs with a synthetic large UTxO so coin selection has // ample room for collateral + change + script fees. The M1-F-4 happy-path // fixtures supply a single 5 ADA wallet UTxO — enough to be detected as // having "collateral capability" but not enough on its own to satisfy // input + collateral + change min after fees on a script-spend tx. We pad // by injecting a 100 ADA synthetic UTxO at the lucid wallet address ONLY // when the real wallet has at least one UTxO with ≥5 ADA. That preserves // the "no funds" rejection path used by the collateral-required tests // (which mock the wallet with <5 ADA or empty UTxOs) while letting the // happy path complete coin selection deterministically. The synthetic // input is never actually broadcast — chain.submitTx receives the final // signed CBOR straight from lucid.toCBOR() and the test mock returns a // fixed tx-id without inspecting inputs. // Use the wallet's real UTxOs only. The earlier synthetic-padding-input // (txHash="0".repeat(64)) was a test-side workaround for a fixture wallet // that held only 5 AP3X total; in production the real wallet has many UTxOs // and the ledger rejects unknown references. ARCHITECTURE.md §9 #14 cleanup. const realWalletUtxos = await lucid.wallet().getUtxos(); assertCollateralCandidate(realWalletUtxos); let signed; try { const txBuilder = ( spendCtx.kind === "ref" ? lucid.newTx().readFrom([spendCtx.refUtxo]) : lucid.newTx().attach.SpendingValidator(spendCtx.script) ) .collectFrom([lucidInput], redeemerHex) .pay.ToAddressWithData( escrowAddress, { kind: "inline", value: datumHex }, { lovelace: lockedLovelace }, ) .addSignerKey(opts.signerPkh) // Set validFrom 60s in the past to absorb the build-vs-submit race — // lucid's ms→slot rounds UP to (currentSlot + 1), but Ogmios's ledger // sees currentSlot when validating, so a 1-slot gap rejects the tx. .validFrom(opts.validFromMs - 60_000) .validTo(opts.validToMs) // lucid-evolution 0.4.30 under-estimates fee for Plutus V3 spends by // ~5K lovelace (Conway minFeeReferenceScripts multiplier appears to be // missed). 0.2 AP3X floor leaves headroom for that under-estimate // without masking the savings from CIP-33 ref-scripts (natural // ref-script tx fee is ~0.24 AP3X; inlined is ~0.34 AP3X — both above // the floor, so lucid's estimate wins and the size delta surfaces). .setMinFee(500_000n); // Let lucid pick coins + compute fee normally; production wallets have // multiple real UTxOs (no need for the test-side leftover-as-fee hack). // // localUPLCEval: false — delegate Plutus V3 phase-2 evaluation to Ogmios // (which uses cardano-node's evaluator). lucid 0.4.30's CML WASM UPLC // interpreter has a false-rejection bug for our V3 spend pattern: it // rejects valid txs with "failed script execution Spend[0] the validator // crashed / exited prematurely" while the chain itself accepts them // without complaint. Routing eval to Ogmios sidesteps the WASM bug AND // produces correct ex-units. The provider already implements evaluateTx // (OgmiosLucidProvider.evaluateTx → JSON-RPC evaluateTransaction). const completed = await txBuilder.complete({ presetWalletInputs: realWalletUtxos, localUPLCEval: false, }); signed = await completed.sign.withWallet().complete(); } catch (err) { rethrowAsTxError(err, "spend-tx build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); await opts.beforeSubmit?.({ txCborHex, expectedTxHash }); await opts.chain.submitTx(txCborHex); return { txCborHex, expectedTxHash }; } // ─── Claim ─────────────────────────────────────────────────────────── export async function buildLiveTxForClaim(params: LiveClaimParams): Promise { return buildSpendOpenOrClaimedTx({ chain: params.chain, walletKey: params.supplierKey, escrowUtxo: params.escrowUtxo, newDatum: params.newDatum, redeemerHex: encodeNullaryConstrHex(REDEEMER_CLAIM), validFromMs: params.tipMs, validToMs: params.deliverBy, signerPkh: params.supplierKey.pubKeyHash, }); } // ─── Submit ────────────────────────────────────────────────────────── export async function buildLiveTxForSubmit(params: LiveSubmitParams): Promise { // Submit { receipt_hash: ByteArray } — Constr1 with a single bytes field. const receiptBytes = hexToBytes(params.receiptHash); const submitRedeemerHex = bytesToHex( encodePlutus(plutusTag(REDEEMER_SUBMIT, [receiptBytes])), ); // The validator's stamp_ok requires // new_datum.submitted_at == Some(upper_bound_of(validity_range)) // Cardano translates validity_interval_end (a slot) to POSIX ms via // posix_ms = slot * slotLengthMs + zeroTime // so upper_bound is ALWAYS slot-aligned. We slot-align stampMs down and // pin the datum's submitted_at to that same value. // // Earlier revision set validFromMs = validToMs = tipMs (slot-aligned). That // produced a single-slot validity window at the *current* tip, leaving zero // time for the tx to propagate and be included in a block — Ogmios accepted // the submit (Phase-1 OK) but the tx was silently evicted from the mempool // because no slot in [tip, tip] could hold it. Push stamp forward to give // the chain SUBMIT_WINDOW_MS of headroom for inclusion, capped at // deliver_by so deadline_ok stays satisfied. const VECTOR_ZERO_TIME = Number(process.env.VECTOR_ZERO_TIME_MS) || 1_752_057_484_000; // ms (env override for mainnet; default = testnet genesis) const SLOT_LENGTH = 1_000; // ms const SUBMIT_WINDOW_MS = 120_000; // 2-minute inclusion window const tipMs = params.tipMs; const targetUpperMs = Math.min(tipMs + SUBMIT_WINDOW_MS, params.deliverBy); const stampSlot = Math.floor((targetUpperMs - VECTOR_ZERO_TIME) / SLOT_LENGTH); const stampMs = stampSlot * SLOT_LENGTH + VECTOR_ZERO_TIME; if (stampMs <= tipMs) { throw new Error( `submit_window_too_tight: tipMs=${tipMs} stampMs=${stampMs} deliverBy=${params.deliverBy}`, ); } const alignedDatum = { ...params.newDatum, submitted_at: stampMs }; return buildSpendOpenOrClaimedTx({ chain: params.chain, walletKey: params.supplierKey, escrowUtxo: params.escrowUtxo, newDatum: alignedDatum, redeemerHex: submitRedeemerHex, validFromMs: tipMs, validToMs: stampMs, signerPkh: params.supplierKey.pubKeyHash, beforeSubmit: params.beforeSubmit, }); } // ─── Accept ────────────────────────────────────────────────────────── export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise { const provider = buildLucidProvider(params.chain); const { lucid } = await createLucidContext(provider, params.buyerKey, { 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 acceptRedeemerHex = encodeNullaryConstrHex(REDEEMER_ACCEPT); // Accept distributes: supplier receives payment+supplier_bond; buyer receives buyer_bond. const supplierDue = params.datum.payment_lovelace + params.datum.supplier_bond_lovelace; const buyerDue = params.datum.buyer_bond_lovelace; // Output addresses must match the lucid network (Mainnet → networkId 1). const supplierAddress = pkhToEnterpriseAddress(params.datum.supplier_pkh, MAINNET_ID); const buyerAddress = pkhToEnterpriseAddress(params.datum.buyer_pkh, MAINNET_ID); // Pad each output to MIN_UTXO_LOVELACE if the bond/payment is below floor — // protocol min-utxo, not a semantic change (the chain enforces floors). const supplierLovelace = supplierDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : supplierDue; const buyerLovelace = buyerDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : buyerDue; // Real wallet UTxOs only; the test-side synthetic padding input is removed // (it caused unknownOutputReferences errors against the real ledger). const realWalletUtxos = await lucid.wallet().getUtxos(); assertCollateralCandidate(realWalletUtxos); const presetWalletInputs = realWalletUtxos; let signed; try { const txBuilder = ( spendCtx.kind === "ref" ? lucid.newTx().readFrom([spendCtx.refUtxo]) : lucid.newTx().attach.SpendingValidator(spendCtx.script) ) .collectFrom([lucidInput], acceptRedeemerHex) .pay.ToAddress(supplierAddress, { lovelace: supplierLovelace }) .pay.ToAddress(buyerAddress, { lovelace: buyerLovelace }) .addSignerKey(params.buyerKey.pubKeyHash) .validFrom(params.tipMs - 60_000) .validTo(params.windowEnd) .setMinFee(500_000n); // Same UPLC-eval delegation as Claim/Submit (see buildSpendOpenOrClaimedTx // for the rationale). lucid 0.4.30's CML WASM evaluator falsely rejects // valid Plutus V3 spends; routing to Ogmios's evaluateTransaction // (cardano-node's evaluator) sidesteps the bug. const completed = await txBuilder.complete({ presetWalletInputs, localUPLCEval: false, }); signed = await completed.sign.withWallet().complete(); } catch (err) { rethrowAsTxError(err, "accept build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); await params.chain.submitTx(txCborHex); // Re-export the constant so callers that import from liveCbor still see // the canonical accept-window value. void ACCEPT_WINDOW_MS; return { txCborHex, expectedTxHash }; } // ─── Reclaim ───────────────────────────────────────────────────────── export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise { const provider = buildLucidProvider(params.chain); const { lucid } = await createLucidContext(provider, params.buyerKey, { 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 reclaimRedeemerHex = encodeNullaryConstrHex(REDEEMER_RECLAIM); // Reclaim returns everything to the buyer: payment + buyer_bond + supplier_bond. const buyerDue = params.datum.payment_lovelace + params.datum.buyer_bond_lovelace + params.datum.supplier_bond_lovelace; const buyerLovelace = buyerDue < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : buyerDue; const buyerAddress = pkhToEnterpriseAddress(params.datum.buyer_pkh, MAINNET_ID); const realWalletUtxos = await lucid.wallet().getUtxos(); assertCollateralCandidate(realWalletUtxos); const presetWalletInputs = realWalletUtxos; let signed; try { const txBuilder = ( spendCtx.kind === "ref" ? lucid.newTx().readFrom([spendCtx.refUtxo]) : lucid.newTx().attach.SpendingValidator(spendCtx.script) ) .collectFrom([lucidInput], reclaimRedeemerHex) .pay.ToAddress(buyerAddress, { lovelace: buyerLovelace }) .addSignerKey(params.buyerKey.pubKeyHash) // Validity range mirrors Accept's pattern: a narrow window centred on // tipMs. The on-chain validator only checks `lower_bound >= deliver_by`, // which holds as long as `tipMs - 60s >= deliver_by` — the caller has // already verified tipMs >= deliver_by, so a 60s back-pad is safe. .validFrom(params.tipMs - 60_000) .validTo(params.tipMs + 60_000) .setMinFee(500_000n); // Same UPLC eval delegation as Accept — 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, "reclaim build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); await params.chain.submitTx(txCborHex); 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( params: LivePostAdvertParams, ): Promise { const { chain, walletKey, advertDatum, deposit_lovelace } = params; const provider = buildLucidProvider(chain); const { lucid } = await createLucidContext(provider, walletKey, { networkId: 1, systemStartUnix: 0, slotLengthMs: 1000, }, { usePresetProtocolParameters: true }); const blueprint = loadBlueprint(); const advertAddress = blueprint.advertScriptAddress(MAINNET_ID); const datumHex = encodeAdvertDatum(advertDatum); // Pad to protocol min-utxo if the supplier bond is below floor — same // pattern as PostEscrow. The validator only checks ≥ supplier_bond_lovelace, // so excess ada in the output is fine. const lockedLovelace = deposit_lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : deposit_lovelace; // postAdvert.ts validates advertised_at is within ±5 min of chain tip; // a centred 5-min window absorbs the build+submit gap. lucid's slot // rounding-up means we still subtract 60s from validFrom like PostEscrow. const advertisedAt = advertDatum.advertised_at; let signed; try { const txBuilder = lucid .newTx() .pay.ToAddressWithData( advertAddress, { kind: "inline", value: datumHex }, { lovelace: lockedLovelace }, ) .addSignerKey(walletKey.pubKeyHash) .validFrom(advertisedAt - 60_000) .validTo(advertisedAt + 5 * 60_000) .setMinFee(500_000n); const completed = await txBuilder.complete(); signed = await completed.sign.withWallet().complete(); } catch (err) { rethrowAsTxError(err, "post-advert build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); const built: PostAdvertBuildResult = { txCborHex, expectedTxHash, advertOutputRef: { txHash: expectedTxHash, index: 0 }, }; await params.beforeSubmit?.(built); await chain.submitTx(txCborHex); return built; } // ─── RetireAdvert ──────────────────────────────────────────────────── export async function buildLiveTxForRetireAdvert( params: LiveRetireAdvertParams, ): Promise { const { chain, walletKey, advertUtxo, datum } = params; const provider = buildLucidProvider(chain); const { lucid } = await createLucidContext(provider, walletKey, { networkId: 1, systemStartUnix: 0, slotLengthMs: 1000, }, { usePresetProtocolParameters: true }); const { script, address: advertAddress } = loadAdvertScript(); const spendCtx = await resolveSpendCtx(lucid, "ADVERT_REF_UTXO", script); const lucidInput = chainUtxoToLucidScriptInput(advertUtxo, advertAddress); const retireRedeemerHex = encodeNullaryConstrHex(REDEEMER_RETIRE_ADVERT); // Validator only requires: (1) signed by datum.supplier_pkh and (2) at // least one output to that pkh's vkh address. Pay the input lovelace // (less fee, handled by lucid coin-selection) back to the supplier. const supplierAddress = pkhToEnterpriseAddress(datum.supplier_pkh, MAINNET_ID); const refundLovelace = advertUtxo.lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : advertUtxo.lovelace; const presetWalletInputs = await lucid.wallet().getUtxos(); assertCollateralCandidate(presetWalletInputs); let signed; try { const nowMs = Date.now(); const txBuilder = ( spendCtx.kind === "ref" ? lucid.newTx().readFrom([spendCtx.refUtxo]) : lucid.newTx().attach.SpendingValidator(spendCtx.script) ) .collectFrom([lucidInput], retireRedeemerHex) .pay.ToAddress(supplierAddress, { lovelace: refundLovelace }) .addSignerKey(walletKey.pubKeyHash) // Validator has no time check, but a sane window absorbs build+submit // propagation lag the same as sibling builders. .validFrom(nowMs - 60_000) .validTo(nowMs + 5 * 60_000) .setMinFee(500_000n); // Same UPLC-eval delegation as Claim/Submit/Accept — see // buildSpendOpenOrClaimedTx for the lucid 0.4.30 false-rejection rationale. const completed = await txBuilder.complete({ presetWalletInputs, localUPLCEval: false, }); signed = await completed.sign.withWallet().complete(); } catch (err) { rethrowAsTxError(err, "retire-advert build failed"); } const txCborHex = signed.toCBOR(); const expectedTxHash = signed.toHash(); await chain.submitTx(txCborHex); return { txCborHex, expectedTxHash }; }