Compare commits
1 Commits
fix/wallet
...
fix/escrow
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c391b98af8 |
@@ -42,6 +42,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
export interface PostChatEscrowParams {
|
export interface PostChatEscrowParams {
|
||||||
@@ -132,7 +133,7 @@ export async function buildPostChatEscrowTx(
|
|||||||
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
const requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||||
const promptHash = chatSessionPromptHash({ session_nonce });
|
const promptHash = chatSessionPromptHash({ session_nonce });
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -154,6 +155,11 @@ export async function buildPostChatEscrowTx(
|
|||||||
state: "Open",
|
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 = []).
|
// Live path: reuse the capability-agnostic chat builder (messages = []).
|
||||||
// The on-chain commitment is the escrowDatum (carrying our session-init
|
// 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.
|
// prompt_hash); messages are only used for the mock cbor body, which we omit.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
export interface PostEscrowParams {
|
export interface PostEscrowParams {
|
||||||
@@ -129,7 +130,7 @@ export async function buildPostEscrowTx(
|
|||||||
// supplier-side validation in M1-C can verify role/content/system integrity.
|
// supplier-side validation in M1-C can verify role/content/system integrity.
|
||||||
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
const promptHash = sha256Utf8Hex(canonicalize(messages));
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -151,6 +152,11 @@ export async function buildPostEscrowTx(
|
|||||||
state: "Open",
|
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.
|
// Live path: produce real Cardano CBOR via lucid-evolution.
|
||||||
if (detectCborBackend(chain) === "live") {
|
if (detectCborBackend(chain) === "live") {
|
||||||
const liveCborPath = "../internal/liveCbor.js";
|
const liveCborPath = "../internal/liveCbor.js";
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { loadBlueprint } from "../blueprint.js";
|
|||||||
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
import { encodeTxBody, sha256Hex } from "../internal/testTxBody.js";
|
||||||
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
import { mockSlotToWallclockMs, NETWORK_BUFFER_MS } from "../internal/constants.js";
|
||||||
import { detectCborBackend } from "../internal/cborBackend.js";
|
import { detectCborBackend } from "../internal/cborBackend.js";
|
||||||
|
import { escrowLockFloor } from "../internal/minAdaFloor.js";
|
||||||
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
|
||||||
|
|
||||||
/** TTS request envelope. The buyer-side hash and the supplier-side hash
|
/** 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 requestSpecHash = sha256Utf8Hex(requestSpecCanonical);
|
||||||
const promptHash = ttsPromptHash(request);
|
const promptHash = ttsPromptHash(request);
|
||||||
|
|
||||||
const totalLocked =
|
const economicTotal =
|
||||||
advertDatum.price_lovelace +
|
advertDatum.price_lovelace +
|
||||||
advertDatum.buyer_bond_lovelace +
|
advertDatum.buyer_bond_lovelace +
|
||||||
advertDatum.supplier_bond_lovelace;
|
advertDatum.supplier_bond_lovelace;
|
||||||
@@ -171,6 +172,11 @@ export async function buildPostTtsEscrowTx(
|
|||||||
state: "Open",
|
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
|
// Live path: reuse the chat builder. The live cbor path is already
|
||||||
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
// capability-agnostic — `messages` is `void`-marked there. We pass an
|
||||||
// empty array so the type checker is satisfied; the actual on-chain
|
// empty array so the type checker is satisfied; the actual on-chain
|
||||||
|
|||||||
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
67
packages/shared/src/tx/internal/minAdaFloor.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
84
tests/unit/tx-minada-floor.test.ts
Normal file
84
tests/unit/tx-minada-floor.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user