diff --git a/packages/shared/src/tx/escrow/postChatEscrow.ts b/packages/shared/src/tx/escrow/postChatEscrow.ts index fba3f53..a66dddd 100644 --- a/packages/shared/src/tx/escrow/postChatEscrow.ts +++ b/packages/shared/src/tx/escrow/postChatEscrow.ts @@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { detectCborBackend } from "../internal/cborBackend.js"; +import { escrowLockFloor } from "../internal/minAdaFloor.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; export interface PostChatEscrowParams { @@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx( const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const promptHash = chatSessionPromptHash({ session_nonce }); - const totalLocked = + const economicTotal = advertDatum.price_lovelace + advertDatum.buyer_bond_lovelace + advertDatum.supplier_bond_lovelace; @@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx( state: "Open", }; + // Lock enough that the escrow output satisfies min-ada in its LARGEST + // future state (Submitted) - value_equal on Claim/Submit forbids any + // later bump (min-ada floor). + const totalLocked = escrowLockFloor(escrowDatum, economicTotal); + // Live path: reuse the capability-agnostic chat builder (messages = []). // The on-chain commitment is the escrowDatum (carrying our session-init // prompt_hash); messages are only used for the mock cbor body, which we omit. diff --git a/packages/shared/src/tx/escrow/postEscrow.ts b/packages/shared/src/tx/escrow/postEscrow.ts index 2cd53b3..9fe8094 100644 --- a/packages/shared/src/tx/escrow/postEscrow.ts +++ b/packages/shared/src/tx/escrow/postEscrow.ts @@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { detectCborBackend } from "../internal/cborBackend.js"; +import { escrowLockFloor } from "../internal/minAdaFloor.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; export interface PostEscrowParams { @@ -129,7 +130,7 @@ export async function buildPostEscrowTx( // supplier-side validation in M1-C can verify role/content/system integrity. const promptHash = sha256Utf8Hex(canonicalize(messages)); - const totalLocked = + const economicTotal = advertDatum.price_lovelace + advertDatum.buyer_bond_lovelace + advertDatum.supplier_bond_lovelace; @@ -151,6 +152,11 @@ export async function buildPostEscrowTx( state: "Open", }; + // Lock enough that the escrow output satisfies min-ada in its LARGEST + // future state (Submitted) - value_equal on Claim/Submit forbids any + // later bump (min-ada floor). + const totalLocked = escrowLockFloor(escrowDatum, economicTotal); + // Live path: produce real Cardano CBOR via lucid-evolution. if (detectCborBackend(chain) === "live") { const liveCborPath = "../internal/liveCbor.js"; diff --git a/packages/shared/src/tx/escrow/postTtsEscrow.ts b/packages/shared/src/tx/escrow/postTtsEscrow.ts index 932b532..0b5bcb7 100644 --- a/packages/shared/src/tx/escrow/postTtsEscrow.ts +++ b/packages/shared/src/tx/escrow/postTtsEscrow.ts @@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js"; import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js"; import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js"; import { detectCborBackend } from "../internal/cborBackend.js"; +import { escrowLockFloor } from "../internal/minAdaFloor.js"; import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js"; /** TTS request envelope. The buyer-side hash and the supplier-side hash @@ -149,7 +150,7 @@ export async function buildPostTtsEscrowTx( const requestSpecHash = sha256Utf8Hex(requestSpecCanonical); const promptHash = ttsPromptHash(request); - const totalLocked = + const economicTotal = advertDatum.price_lovelace + advertDatum.buyer_bond_lovelace + advertDatum.supplier_bond_lovelace; @@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx( state: "Open", }; + // Lock enough that the escrow output satisfies min-ada in its LARGEST + // future state (Submitted) - value_equal on Claim/Submit forbids any + // later bump (min-ada floor). + const totalLocked = escrowLockFloor(escrowDatum, economicTotal); + // Live path: reuse the chat builder. The live cbor path is already // capability-agnostic — `messages` is `void`-marked there. We pass an // empty array so the type checker is satisfied; the actual on-chain diff --git a/packages/shared/src/tx/internal/minAdaFloor.ts b/packages/shared/src/tx/internal/minAdaFloor.ts new file mode 100644 index 0000000..5c86ee2 --- /dev/null +++ b/packages/shared/src/tx/internal/minAdaFloor.ts @@ -0,0 +1,67 @@ +/** + * minAdaFloor.ts — min-ada floor for the escrow's LARGEST future datum. + * + * The escrow validator requires value_equal(continuing, in) on Claim and + * Submit, so the lovelace locked at POST time must already satisfy min-ada + * for every datum the output will ever carry. The largest is the Submitted + * state: submitted_at None -> Some(POSIX ms) and result_receipt_hash + * None -> Some(32 bytes) add ~43 bytes over Open/Claimed. If the posted + * lovelace sits below that state's min-ada, lucid bumps the Submit + * continuing output to min-ada, value_equal fails, and the validator + * errors with no traces (the 2026-08-07 mainnet incident: the 33-byte + * "ocr.page.extract.chandra-ocr-2.v1" capability_id pushed the Submitted + * output 11,030 lovelace over a 2,200,000 lock; the 20-byte chat + * capability stayed 49,310 under, which is why only OCR Submits failed). + * + * escrowLockFloor() returns the lovelace to lock: the economic total + * (payment + both bonds), raised to the Submitted-state min-ada plus a + * safety margin when the total alone is too small. + */ + +import type { EscrowDatum } from "../../cbor/types.js"; +import { encodeEscrowDatum } from "../../cbor/EscrowDatum.js"; + +/** Conway/Babbage utxoCostPerByte on Vector mainnet (protocol params). */ +const UTXO_COST_PER_BYTE = 4310n; + +/** Ledger constant: min-ada = (160 + serialized output bytes) * costPerByte. */ +const OUTPUT_OVERHEAD_BYTES = 160n; + +/** Covers CBOR framing drift between this estimate and the ledger's exact + * serialization (address, coin width, datum wrapper). Generous on purpose: + * 50k lovelace = 0.05 AP3X, dwarfed by per-leg tx fees. */ +const SAFETY_MARGIN_LOVELACE = 50_000n; + +/** Serialized-output byte estimate for an enterprise script address output + * carrying `lovelace` and an inline datum of `datumBytes`. Mirrors the + * arithmetic validated against mainnet on 2026-08-07 (minada-calc). */ +function estimateOutputBytes(datumBytes: number, lovelace: bigint): bigint { + const addr = 1n + 29n; + const coin = 1n + (lovelace > 0xffffffffn ? 8n : 4n); + const datumField = 1n + 2n + 3n + BigInt(datumBytes); + const mapOverhead = 3n; + return addr + coin + datumField + mapOverhead; +} + +/** Min-ada for the escrow output when it carries `datum`. */ +export function minAdaForEscrowDatum(datum: EscrowDatum, lovelace: bigint): bigint { + const datumBytes = encodeEscrowDatum(datum).length / 2; + const size = estimateOutputBytes(datumBytes, lovelace); + return (OUTPUT_OVERHEAD_BYTES + size) * UTXO_COST_PER_BYTE; +} + +/** The lovelace a POST must lock so the escrow output satisfies min-ada in + * every reachable state, including Submitted. `economicTotal` is + * payment + buyer_bond + supplier_bond. */ +export function escrowLockFloor(openDatum: EscrowDatum, economicTotal: bigint): bigint { + const submittedShape: EscrowDatum = { + ...openDatum, + // Representative worst-case Submit fields: a 13-digit POSIX ms stamp + // and a 32-byte receipt hash — sizes, not values, matter here. + submitted_at: openDatum.deliver_by, + result_receipt_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + state: "Submitted", + }; + const floor = minAdaForEscrowDatum(submittedShape, economicTotal) + SAFETY_MARGIN_LOVELACE; + return floor > economicTotal ? floor : economicTotal; +} diff --git a/tests/unit/tx-minada-floor.test.ts b/tests/unit/tx-minada-floor.test.ts new file mode 100644 index 0000000..51cf11f --- /dev/null +++ b/tests/unit/tx-minada-floor.test.ts @@ -0,0 +1,84 @@ +/** + * tx-minada-floor.test.ts — the escrow min-ada floor. + * + * The escrow output locks payment + both bonds and the validator requires + * exact value preservation (value_equal) on Claim and Submit. Submit grows + * the inline datum (submitted_at None->Some, result_receipt_hash None->Some + * 32 bytes), so the lovelace locked at POST time must already cover min-ada + * for the Submitted state. A capability_id long enough to push the + * Submitted output's min-ada past the economic total made lucid bump the + * continuing output, value_equal failed, and the validator errored with no + * traces — so escrows with short capability ids settled and long ones never + * could. escrowLockFloor closes that by locking the max of the economic + * total and the Submitted-state min-ada plus a margin. + */ + +import { describe, it, expect } from "vitest"; +import { + escrowLockFloor, + minAdaForEscrowDatum, +} from "../../packages/shared/src/tx/internal/minAdaFloor.js"; +import type { EscrowDatum } from "../../packages/shared/src/cbor/types.js"; + +function openDatum(capabilityId: string): EscrowDatum { + return { + buyer_pkh: "3b2fdaed0398485ff35887fb7d0ad2025348864407854f7831dc6a55", + supplier_pkh: "401af418a7e6fb106277292881fcdeaa1cc15b5b112e9c8bdb2d46c9", + advert_ref: { txHash: "a6".repeat(32), index: 0 }, + capability_id: capabilityId, + request_spec_hash: "1b".repeat(32), + prompt_hash: "23".repeat(32), + payment_lovelace: 200_000n, + buyer_bond_lovelace: 1_000_000n, + supplier_bond_lovelace: 1_000_000n, + deliver_by: 1_786_105_955_257, + posted_at: 1_786_105_625_257, + submitted_at: null, + result_receipt_hash: null, + state: "Open", + }; +} + +const ECONOMIC_TOTAL = 2_200_000n; // 0.2 payment + 1.0 + 1.0 bonds + +describe("escrowLockFloor", () => { + it("barely lifts a short-capability escrow (margin only, no capability-driven bump)", () => { + // The 20-byte chat capability's Submitted-state min-ada is ~2.15M, under + // the 2.2M economic total; only the safety margin nudges the lock, so it + // stays within a fraction of a percent of payment + bonds. + const datum = openDatum("llm.text.generate.v1"); + const floor = escrowLockFloor(datum, ECONOMIC_TOTAL); + expect(floor).toBeGreaterThanOrEqual(ECONOMIC_TOTAL); + expect(floor).toBeLessThan(ECONOMIC_TOTAL + 100_000n); // << the OCR lift + }); + + it("raises a long-capability escrow above the economic total", () => { + // The 33-byte OCR capability that could not settle: its Submitted-state + // output needs more than 2.2M, so the floor must lift the lock. + const datum = openDatum("ocr.page.extract.chandra-ocr-2.v1"); + const floor = escrowLockFloor(datum, ECONOMIC_TOTAL); + expect(floor).toBeGreaterThan(ECONOMIC_TOTAL); + // The locked value must satisfy the Submitted-state min-ada it is sized for. + const submitted: EscrowDatum = { + ...datum, + submitted_at: datum.deliver_by, + result_receipt_hash: "ff".repeat(32), + state: "Submitted", + }; + expect(floor).toBeGreaterThanOrEqual(minAdaForEscrowDatum(submitted, floor)); + }); + + it("never returns less than the economic total", () => { + for (const cap of ["a", "x".repeat(64), "tts.speak.v1"]) { + expect(escrowLockFloor(openDatum(cap), ECONOMIC_TOTAL)).toBeGreaterThanOrEqual( + ECONOMIC_TOTAL, + ); + } + }); + + it("the floor is monotonic in capability-id length", () => { + const short = escrowLockFloor(openDatum("a.b.v1"), ECONOMIC_TOTAL); + const long = escrowLockFloor(openDatum("a.very.long.capability.id.that.keeps.going.v1"), ECONOMIC_TOTAL); + expect(long).toBeGreaterThanOrEqual(short); + }); +});