Files
agents-marketplace/gateway/src/onchain/preflight.ts
FilipBl4gojevic 2b2295b7ed fix(ocr): review pass — min-ada floor regression test, auth-before-parse, failure-path reconciliation, disclosure label coverage
Fixes 2 critical, 2 important, and 5 minor findings from review of 069edcb:

- C1: add a happy-path test that reaches postOcrEscrow.ts's escrowLockFloor
  call and asserts the locked amount; verified it fails against a bare
  economicTotal and passes against the real code.
- C2: gate /v1/ocr/extract (auth + rate limits) before the 16mb body parser
  so an unauthenticated caller can no longer force a full buffer/JSON.parse.
- I2: hoist result/used/escrowRef in the OCR one-shot handler so the failure
  path records escrow_ref/supplier/cost when an escrow was actually posted,
  and emit an ocr_failed structured log line mirroring ocr_settled.
- I3: export venueAttribution and cover its sanitization directly; add a
  stubbed happy-path route test asserting the first-party-brokered
  disclosure label reaches the response.
- Minor: drop unused hfWeightsSource end to end, rename a misleading test,
  make the OCR capability template's pinning language conditional on the
  self-hosted upstream, tighten the /v1/ocr path-prefix guard, and make
  preflight's required_lovelace use the same escrowLockFloor the builder
  locks (exported from the shared tx barrel for gateway use).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:12:12 +02:00

90 lines
3.4 KiB
TypeScript

/**
* gateway/src/onchain/preflight.ts — balance + collateral pre-flight.
*
* The buyer wallet funds the FULL escrow at PostEscrow: price + buyer_bond +
* supplier_bond, raised to the Submitted-state min-ada floor so long
* capability ids (e.g. the 33-byte OCR one) don't get bumped mid-flight —
* see escrowLockFloor (packages/shared/.../minAdaFloor.ts) and
* postOcrEscrow.ts:205, the SAME floor a POST actually locks. Accept/Reclaim
* are Plutus script-spends that also need a pure-AP3X collateral UTxO ≥ 5
* AP3X (assertCollateralCandidate). So required = escrowLockFloor(price +
* buyer_bond + supplier_bond) + collateral + fee reserve, AND the wallet
* must currently hold a pure-AP3X UTxO ≥ collateral. We check both up front
* and return a clean 402 instead of failing mid-flight.
*/
import type { ChainProvider, Utxo } from "@marketplace/shared/chain";
import type { EscrowDatum } from "@marketplace/shared/cbor";
import { escrowLockFloor } from "@marketplace/shared/tx";
export const COLLATERAL_MIN_LOVELACE = 5_000_000n;
export const FEE_RESERVE_LOVELACE = 2_000_000n;
export function totalLovelace(utxos: Utxo[]): bigint {
return utxos.reduce((sum, u) => sum + u.lovelace, 0n);
}
/** True iff some UTxO is pure-AP3X (no native assets) and ≥ collateral floor. */
export function hasCollateral(utxos: Utxo[]): boolean {
return utxos.some(
(u) => u.lovelace >= COLLATERAL_MIN_LOVELACE && Object.keys(u.assets).length === 0,
);
}
export interface PreflightResult {
ok: boolean;
availableLovelace: bigint;
requiredLovelace: bigint;
collateralOk: boolean;
}
export interface PreflightCost {
capabilityId: string;
priceLovelace: bigint;
buyerBondLovelace: bigint;
supplierBondLovelace: bigint;
}
/** Placeholder datum used only to size-estimate the Submitted-state min-ada
* floor. Every field except capability_id and the cost amounts is a
* fixed-size stand-in (real hash/pkh byte widths, a realistic 13-digit
* POSIX-ms timestamp) — capability_id's actual length is what drives the
* estimate, exactly as in escrowLockFloor's own contract. */
function representativeEscrowDatum(cost: PreflightCost): EscrowDatum {
return {
buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55",
supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9",
advert_ref: { txHash: "a6".repeat(32), index: 0 },
capability_id: cost.capabilityId,
request_spec_hash: "1b".repeat(32),
prompt_hash: "23".repeat(32),
payment_lovelace: cost.priceLovelace,
buyer_bond_lovelace: cost.buyerBondLovelace,
supplier_bond_lovelace: cost.supplierBondLovelace,
deliver_by: 1_786_105_955_257,
posted_at: 1_786_105_625_257,
submitted_at: null,
result_receipt_hash: null,
state: "Open",
};
}
export async function preflight(
chain: ChainProvider,
address: string,
cost: PreflightCost,
): Promise<PreflightResult> {
const utxos = await chain.queryUtxosByAddress(address);
const available = totalLovelace(utxos);
const collateralOk = hasCollateral(utxos);
const economicTotal = cost.priceLovelace + cost.buyerBondLovelace + cost.supplierBondLovelace;
const escrowFloor = escrowLockFloor(representativeEscrowDatum(cost), economicTotal);
const required = escrowFloor + COLLATERAL_MIN_LOVELACE + FEE_RESERVE_LOVELACE;
return {
ok: available >= required && collateralOk,
availableLovelace: available,
requiredLovelace: required,
collateralOk,
};
}