Compare commits
1 Commits
fix/escrow
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5fdd1f1b7 |
@@ -19,26 +19,6 @@ import { badRequest, forbidden } from "../openai/errors.js";
|
||||
|
||||
const ACTIVE_ESCROW_STATES = new Set(["Open", "Claimed", "Submitted"]);
|
||||
|
||||
/** Bound on per-request escrow liveness lookups; rows past the cap count as
|
||||
* locked unverified (a key with this many active escrows has bigger problems). */
|
||||
const LIVENESS_CHECK_CAP = 32;
|
||||
|
||||
/** True when the escrow UTxO behind an indexer row is still unspent. The
|
||||
* indexer never updates rows on terminal spends (Accept/Reclaim/Release
|
||||
* consume the escrow without a new escrow output), so "active" rows can be
|
||||
* long spent. Unparseable refs and failed lookups count as live so errors
|
||||
* inflate the locked figure rather than hide funds. */
|
||||
async function escrowUtxoIsLive(deps: GatewayDeps, utxoRef: string): Promise<boolean> {
|
||||
const m = /^([0-9a-fA-F]{64})#(\d+)$/.exec(utxoRef);
|
||||
if (!m) return true;
|
||||
try {
|
||||
const utxo = await deps.chain.queryUtxo({ txHash: m[1], index: Number(m[2]) });
|
||||
return utxo !== null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function ap3x(lovelace: bigint): string {
|
||||
return (Number(lovelace) / 1e6).toFixed(2);
|
||||
}
|
||||
@@ -94,15 +74,8 @@ export function makeAccountHandler(deps: GatewayDeps) {
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as Array<Record<string, unknown>>;
|
||||
if (Array.isArray(rows)) {
|
||||
const active = rows.filter((row) => ACTIVE_ESCROW_STATES.has(String(row.state)));
|
||||
const liveness = await Promise.all(
|
||||
active.map((row, i) =>
|
||||
i < LIVENESS_CHECK_CAP ? escrowUtxoIsLive(deps, String(row.utxo_ref ?? "")) : Promise.resolve(true),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < active.length; i++) {
|
||||
if (!liveness[i]) continue;
|
||||
const row = active[i];
|
||||
for (const row of rows) {
|
||||
if (!ACTIVE_ESCROW_STATES.has(String(row.state))) continue;
|
||||
lockedInEscrow +=
|
||||
BigInt(String(row.payment_lovelace ?? "0")) +
|
||||
BigInt(String(row.buyer_bond_lovelace ?? "0")) +
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
/**
|
||||
* gateway-account-locked.test.ts — /account locked_in_escrow_lovelace accuracy.
|
||||
*
|
||||
* The indexer never updates escrow rows on terminal spends (Accept/Reclaim/
|
||||
* Release consume the UTxO without producing a new escrow output), so rows
|
||||
* can sit in an "active" state long after the funds moved. The account
|
||||
* handler must verify each active row's UTxO is still unspent on-chain
|
||||
* before counting it as locked; a spent ref is a stale row, not locked funds.
|
||||
*
|
||||
* Note: supplier_bond stays in the sum on purpose — the buyer fronts
|
||||
* payment + buyer_bond + supplier_bond at PostEscrow (see postEscrow.ts
|
||||
* totalLocked), so all three legs are the key's money while the escrow lives.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Request, Response } from "express";
|
||||
import { makeAccountHandler } from "../../gateway/src/account/routes.js";
|
||||
|
||||
const LIVE_REF = "a".repeat(64) + "#0";
|
||||
const STALE_REF = "b".repeat(64) + "#1";
|
||||
|
||||
function escrowRow(utxoRef: string, state: string) {
|
||||
return {
|
||||
utxo_ref: utxoRef,
|
||||
state,
|
||||
payment_lovelace: "2000000",
|
||||
buyer_bond_lovelace: "1000000",
|
||||
supplier_bond_lovelace: "1000000",
|
||||
};
|
||||
}
|
||||
|
||||
function fakeKeyRow() {
|
||||
return {
|
||||
id: "key-1",
|
||||
key_hash: "h",
|
||||
key_prefix: "vk_live_abc",
|
||||
label: null,
|
||||
wallet_pkh: "c".repeat(56),
|
||||
deposit_address: "addr1_deposit",
|
||||
enc_priv_nonce: "",
|
||||
enc_priv_ct: "",
|
||||
enc_priv_tag: "",
|
||||
master_key_version: 1,
|
||||
created_at: 0,
|
||||
disabled: 0,
|
||||
demo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(rows: Array<Record<string, unknown>>, opts?: { queryUtxo?: (ref: { txHash: string; index: number }) => Promise<unknown> }) {
|
||||
const queryUtxo =
|
||||
opts?.queryUtxo ??
|
||||
(async (ref: { txHash: string; index: number }) => {
|
||||
const canonical = `${ref.txHash}#${ref.index}`;
|
||||
return canonical === LIVE_REF ? { ref, lovelace: 4_000_000n } : null;
|
||||
});
|
||||
return {
|
||||
config: { indexerUrl: "http://indexer.test", networkId: 1, masterKeyHex: "00" },
|
||||
store: {
|
||||
sumCostLovelace: () => 0n,
|
||||
countUsage: () => 0,
|
||||
listUsage: () => [],
|
||||
},
|
||||
chain: {
|
||||
queryUtxosByAddress: async () => [],
|
||||
queryUtxo: vi.fn(queryUtxo),
|
||||
},
|
||||
registry: {},
|
||||
fetchFn: vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => rows,
|
||||
})),
|
||||
} as never;
|
||||
}
|
||||
|
||||
/**
|
||||
* asyncHandler returns a fire-and-forget (req, res) => void wrapper, so the
|
||||
* test must await the res.json() call itself, not the handler invocation.
|
||||
* Errors also surface through json() (sendError maps them to a JSON body).
|
||||
*/
|
||||
async function callAccount(deps: never): Promise<Record<string, never>> {
|
||||
const handler = makeAccountHandler(deps);
|
||||
const req = { gatewayKey: fakeKeyRow() } as unknown as Request;
|
||||
const body = await new Promise<unknown>((resolveBody, rejectBody) => {
|
||||
const timer = setTimeout(() => rejectBody(new Error("handler never responded")), 2_000);
|
||||
const res = {
|
||||
status: (_code: number) => res,
|
||||
json: (b: unknown) => {
|
||||
clearTimeout(timer);
|
||||
resolveBody(b);
|
||||
return res;
|
||||
},
|
||||
} as unknown as Response;
|
||||
handler(req, res);
|
||||
});
|
||||
if ((body as { error?: unknown }).error) {
|
||||
throw new Error(`handler returned error body: ${JSON.stringify(body)}`);
|
||||
}
|
||||
return body as Record<string, never>;
|
||||
}
|
||||
|
||||
describe("GET /account — locked_in_escrow_lovelace", () => {
|
||||
it("counts payment + both bonds for an active escrow whose UTxO is live", async () => {
|
||||
const body = await callAccount(makeDeps([escrowRow(LIVE_REF, "Open")]));
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
|
||||
it("excludes an active-state row whose UTxO is terminally spent (stale indexer row)", async () => {
|
||||
const body = await callAccount(
|
||||
makeDeps([escrowRow(LIVE_REF, "Open"), escrowRow(STALE_REF, "Submitted")]),
|
||||
);
|
||||
// Only the live escrow counts: 4_000_000, not 8_000_000.
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
|
||||
it("skips terminal-state rows without a chain lookup", async () => {
|
||||
const deps = makeDeps([escrowRow(LIVE_REF, "Accepted"), escrowRow(STALE_REF, "Reclaimed")]);
|
||||
const body = await callAccount(deps);
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("0");
|
||||
expect((deps as { chain: { queryUtxo: ReturnType<typeof vi.fn> } }).chain.queryUtxo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts a row conservatively when the chain lookup fails (best-effort)", async () => {
|
||||
const deps = makeDeps([escrowRow(LIVE_REF, "Open")], {
|
||||
queryUtxo: async () => {
|
||||
throw new Error("ogmios unreachable");
|
||||
},
|
||||
});
|
||||
const body = await callAccount(deps);
|
||||
expect(body.balance.locked_in_escrow_lovelace).toBe("4000000");
|
||||
});
|
||||
});
|
||||
83
tests/unit/tx-release-live-dispatch.test.ts
Normal file
83
tests/unit/tx-release-live-dispatch.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user