6 Commits

Author SHA1 Message Date
HalukG
47b3c4efcd feat(deploy): add HF-router Llama-3.3-70B mainnet supplier (hf-llama33)
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
OpenAI-compatible supplier backed by the HuggingFace Inference Providers router
(meta-llama/Llama-3.3-70B-Instruct, llm.text.generate.v1). Config only; mirrors
the glm one-off template with OPENAI_BASE_URL=https://router.huggingface.co.

- deploy/mainnet/docker-compose.supplier-hf-llama33.yml
- register the supplier wallet in monitor-wallets OPERATOR_SOURCES

The env file (supplier/.env.hf-llama33) holds the wallet key + HF token and is
never committed; wallet-monitor/wallets.json is gitignored runtime data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 14:25:14 +03:00
dcejtlin-apex
7db4de1f75 Merge pull request #14 from Apex-Fusion/fix/release-tx-builder
feat(tx): live CBOR builder for Release - Submitted strays become resolvable
2026-08-17 09:53:46 +02:00
dcejtlin-apex
133102bc31 Merge pull request #15 from Apex-Fusion/fix/gateway-mutex-deadline
fix(gateway): per-run deadline + queue bound on Mutex (08-12 freeze fix)
2026-08-17 09:45:15 +02:00
dcejtlin-apex
6fbf0d0f45 Merge pull request #16 from Apex-Fusion/fix/escrow-locked-overcount
fix(gateway): stop counting terminally-spent escrows as locked funds
2026-08-17 09:44:56 +02:00
FilipBl4gojevic
b3b7d6b5e1 fix(gateway): per-run deadline + queue bound on Mutex - 08-12 freeze fix
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
One chain call that never settled, run under the per-key Mutex with no
deadline, convoyed every subsequent request on that key silently while
/models and /account stayed healthy (2026-08-12 incident). The per-fetch
30s aborts in the Ogmios providers predate the freeze and did not prevent
it; the unbounded thing was the composition under the mutex.

Mutex.run() now races each fn against a deadline (default 180s): on
expiry the run rejects with MutexTimeoutError and the chain advances, so
queued work proceeds instead of freezing. A zombie that settles later can
at worst lose a UTxO-contention race with the run that replaced it -
strictly better than a permanent convoy. New work is refused with
MutexQueueFullError past a queue bound (default 64) instead of queueing
without limit. Serialization and rejection-tolerance semantics are
unchanged (covered by tests). Session locks in transcripts.ts construct
the same class and inherit both protections.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 09:56:41 +02:00
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
7 changed files with 482 additions and 10 deletions

View File

@@ -41,6 +41,13 @@ interface OperatorSource {
}
const OPERATOR_SOURCES: OperatorSource[] = [
{
label: "supplier-hf-llama33@vector-marketplace",
host: "root@91.98.147.172",
envPath: "/root/agents-marketplace/supplier/.env.hf-llama33",
keyVar: "SUPPLIER_PRIV_KEY_HEX",
role: "supplier",
},
{
label: "buyer@vector-marketplace",
host: "root@91.98.147.172",

View File

@@ -0,0 +1,92 @@
# deploy/mainnet/docker-compose.supplier-hf-llama33.yml — Supplier — MAINNET
# (HuggingFace Inference Providers router / meta-llama/Llama-3.3-70B-Instruct
# — one-off `llm.text.generate.v1`)
#
# HF-router-backed supplier. LLM_BACKEND=openai pointed at the HuggingFace
# Inference Providers router (OpenAI-compatible at https://router.huggingface.co).
# No local proxy container — reachable over public HTTPS. Direct analogue of the
# OpenRouter/Hetzner-backed suppliers; the router is OpenAI-compatible so this is
# config only (V1 HF integration).
#
# Pre-flight:
# 1. DNS: mp-suppliers-hf-llama33.vector.apexfusion.org → host
# (individual DNS-only A record → 91.98.147.172; NO wildcard exists —
# letsencrypt HTTP-01 issuance needs the unproxied direct record)
# 2. supplier/.env.hf-llama33 written on host (chmod 600) with at minimum:
# SUPPLIER_PRIV_KEY_HEX, SUPPLIER_ADDRESS, SUPPLIER_PKH,
# SUPPLIER_PUB_KEY_HEX, OPENAI_API_KEY (hf_ token), OGMIOS_URL,
# NETWORK_ID=1, VECTOR_ZERO_TIME_MS, ESCROW_REF_UTXO, ADVERT_REF_UTXO,
# ADVERT_REF, LIVE_CHAIN=1
# Advert posted with --capability-id llm.text.generate.v1.
# Do NOT set OPENAI_REASONING — the HF router rejects it (HTTP 400) and the
# supplier's config guard refuses to boot.
# 3. Networks already exist (created by the canonical supplier compose):
# docker network create marketplace-mainnet-net # one-shot
# # traefik-net is created by the Traefik project
name: marketplace-mainnet-supplier-hf-llama33
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier-hf-llama33
restart: unless-stopped
env_file:
# Self-contained env for this HF-router-backed variant. Holds this
# supplier's distinct SUPPLIER_PRIV_KEY_HEX (+ derived wallet vars),
# ADVERT_REF, the HF OPENAI_API_KEY, and the shared mainnet plumbing
# duplicated in full (NETWORK_ID, OGMIOS_URL, VECTOR_ZERO_TIME_MS,
# ref-script UTxOs, LIVE_CHAIN). No env_file ordering / override magic.
- path: ../../supplier/.env.hf-llama33
required: false
environment:
LLM_BACKEND: openai
# No /v1 suffix — supplier/src/openai.ts appends /v1/chat/completions
# (HF router serves at https://router.huggingface.co/v1/...).
OPENAI_BASE_URL: https://router.huggingface.co
# First-token latency across hosted providers can exceed local backends;
# kept ≤ the advert's max_processing_ms (300000) per fleet footgun #4.
OPENAI_TIMEOUT_MS: "300000"
# OPENAI_MAX_TOKENS intentionally unset: no max_tokens forwarded upstream,
# the provider grants context − prompt. Advert cap = model context length.
PORT: "8080"
# Enable LiveOgmiosProvider so submitTx/awaitTx actually broadcast.
LIVE_CHAIN: "1"
# NETWORK_ID and OGMIOS_URL intentionally not pinned here — they MUST
# come from the operator-provided supplier/.env.hf-llama33 on a mainnet host.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet.middlewares=mp-suppliers-hf-llama33-mainnet-https"
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.scheme=https"
- "traefik.http.middlewares.mp-suppliers-hf-llama33-mainnet-https.redirectscheme.permanent=true"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.entrypoints=websecure"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.rule=Host(`mp-suppliers-hf-llama33.vector.apexfusion.org`)"
- "traefik.http.routers.mp-suppliers-hf-llama33-mainnet-tls.tls.certresolver=letsencrypt"
- "traefik.http.services.mp-suppliers-hf-llama33-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: traefik-net

View File

@@ -15,19 +15,88 @@ import { open as unseal } from "../crypto/seal.js";
import { deriveWalletKey } from "../wallet.js";
import type { ApiKeyRow } from "../db/store.js";
/** Tiny promise-chain mutex: run(fn) executes fns strictly one at a time. */
/** Default per-run deadline. Chain reads and tx submits settle in seconds;
* anything past this is the 2026-08-12 freeze mode (a fetch that never
* settles), not a slow success. */
export const DEFAULT_MUTEX_TIMEOUT_MS = 180_000;
/** Default queue bound per mutex. A healthy key drains its queue in seconds;
* a queue this deep means the head is stuck and callers should fail fast. */
export const DEFAULT_MUTEX_MAX_QUEUE = 64;
/** A run() exceeded its deadline and was abandoned so queued work can proceed. */
export class MutexTimeoutError extends Error {
constructor(label: string, timeoutMs: number) {
super(`mutex run '${label}' exceeded ${timeoutMs}ms and was abandoned; queued work proceeds`);
this.name = "MutexTimeoutError";
}
}
/** run() was refused because the queue is at its bound (head likely stuck). */
export class MutexQueueFullError extends Error {
constructor(label: string, depth: number) {
super(`mutex queue full (${depth} pending) — refusing '${label}'; the head of the queue is likely stuck`);
this.name = "MutexQueueFullError";
}
}
function runWithDeadline<T>(fn: () => Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => rejectPromise(new MutexTimeoutError(label, timeoutMs)), timeoutMs);
// Node-only nicety: don't hold the process open for a pending deadline.
timer.unref?.();
fn().then(
(v) => {
clearTimeout(timer);
resolvePromise(v);
},
(e) => {
clearTimeout(timer);
rejectPromise(e);
},
);
});
}
/**
* Tiny promise-chain mutex: run(fn) executes fns strictly one at a time —
* now with a per-run deadline and a queue bound (2026-08-12 freeze fix).
*
* On deadline the stuck fn is NOT cancelled (promises aren't); it is
* abandoned: its run() rejects with MutexTimeoutError and the chain advances.
* A zombie that settles later can at worst lose a UTxO-contention race with
* the run that replaced it — strictly better than the alternative, where one
* stuck chain call convoys every subsequent request on the key forever.
*/
export class Mutex {
private tail: Promise<unknown> = Promise.resolve();
private depth = 0;
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.tail.then(fn, fn);
// Keep the chain alive even if fn rejects; swallow here so the next
// run() isn't poisoned by a prior rejection.
this.tail = result.then(
constructor(private readonly opts: { timeoutMs?: number; maxQueue?: number } = {}) {}
run<T>(fn: () => Promise<T>, label = "op"): Promise<T> {
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_MUTEX_TIMEOUT_MS;
const maxQueue = this.opts.maxQueue ?? DEFAULT_MUTEX_MAX_QUEUE;
if (this.depth >= maxQueue) {
return Promise.reject(new MutexQueueFullError(label, this.depth));
}
this.depth++;
// Keep the chain alive even if fn rejects or times out; swallow here so
// the next run() isn't poisoned by a prior rejection.
const result = this.tail.then(
() => runWithDeadline(fn, timeoutMs, label),
() => runWithDeadline(fn, timeoutMs, label),
);
const settled = result.finally(() => {
this.depth--;
});
this.tail = settled.then(
() => undefined,
() => undefined,
);
return result;
return settled;
}
}

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,121 @@
/**
* gateway-mutex.test.ts — Mutex deadline + queue bound (2026-08-12 freeze fix).
*
* Incident: one chain call that never settled, run under a per-key Mutex with
* no deadline, convoyed every subsequent request on that key silently while
* /models and /account stayed healthy. The fix gives run() a deadline that
* abandons the stuck operation (advancing the chain) and a queue bound that
* rejects new work instead of queueing without limit.
*
* Serialization semantics of the original class must be preserved.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
Mutex,
MutexTimeoutError,
MutexQueueFullError,
} from "../../gateway/src/sdk/registry.js";
afterEach(() => {
vi.useRealTimers();
});
// ─── Preserved semantics ─────────────────────────────────────────────────────
describe("Mutex — serialization (preserved semantics)", () => {
it("runs fns strictly one at a time, in order", async () => {
const mutex = new Mutex();
const order: number[] = [];
let firstRunning = false;
const first = mutex.run(async () => {
firstRunning = true;
await new Promise((r) => setTimeout(r, 20));
order.push(1);
firstRunning = false;
});
const second = mutex.run(async () => {
expect(firstRunning).toBe(false);
order.push(2);
});
await Promise.all([first, second]);
expect(order).toEqual([1, 2]);
});
it("a rejecting fn does not poison the chain", async () => {
const mutex = new Mutex();
await expect(mutex.run(async () => Promise.reject(new Error("boom")))).rejects.toThrow("boom");
await expect(mutex.run(async () => "alive")).resolves.toBe("alive");
});
});
// ─── Deadline (the freeze fix) ───────────────────────────────────────────────
describe("Mutex — per-run deadline", () => {
it("rejects a stuck run with MutexTimeoutError once the deadline passes", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck-chain-read");
// Attach the rejection expectation BEFORE advancing time so the rejection
// is not treated as unhandled.
const assertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
await vi.advanceTimersByTimeAsync(1_001);
await assertion;
});
it("queued work proceeds after a stuck run is abandoned (no convoy)", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const stuck = mutex.run(() => new Promise<never>(() => {}), "stuck");
const stuckAssertion = expect(stuck).rejects.toBeInstanceOf(MutexTimeoutError);
const queued = mutex.run(async () => "unblocked");
await vi.advanceTimersByTimeAsync(1_001);
await stuckAssertion;
await expect(queued).resolves.toBe("unblocked");
});
it("a run that finishes in time is unaffected by the deadline", async () => {
vi.useFakeTimers();
const mutex = new Mutex({ timeoutMs: 1_000 });
const quick = mutex.run(async () => "done");
await vi.advanceTimersByTimeAsync(1);
await expect(quick).resolves.toBe("done");
});
});
// ─── Queue bound ─────────────────────────────────────────────────────────────
describe("Mutex — queue bound", () => {
it("rejects immediately with MutexQueueFullError when the queue is full", async () => {
const mutex = new Mutex({ maxQueue: 2 });
let releaseFirst!: () => void;
const gate = new Promise<void>((r) => {
releaseFirst = r;
});
const first = mutex.run(() => gate.then(() => "first"));
const second = mutex.run(async () => "second");
const third = mutex.run(async () => "third");
await expect(third).rejects.toBeInstanceOf(MutexQueueFullError);
releaseFirst();
await expect(first).resolves.toBe("first");
await expect(second).resolves.toBe("second");
});
it("slots free up once runs settle", async () => {
const mutex = new Mutex({ maxQueue: 1 });
await mutex.run(async () => "a");
await expect(mutex.run(async () => "b")).resolves.toBe("b");
});
});

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