fix(escrow): reserve pure-ADA collateral UTxO in PostEscrow coin selection
buildLiveTxForEscrow called .complete() with no presetWalletInputs, so lucid was free to spend the dedicated 5-ADA collateral UTxO as an ordinary input. On back-to-back requests this left the wallet with no collateral candidate and the subsequent Accept failed with collateral_required, stranding the escrow. Reserve the smallest pure-ADA UTxO >= 5 ADA and fund PostEscrow from the rest (falls back to default selection for single/large-UTxO cases, which keep an ample pure-ADA change UTxO anyway). Benefits the buyer too (same shared path). Adds reserveCollateralInputs unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -358,6 +358,45 @@ function rethrowAsTxError(err: unknown, fallback: string): never {
|
|||||||
throw new TxConstructionError(fallback, msg);
|
throw new TxConstructionError(fallback, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Choose PostEscrow inputs that PRESERVE a pure-ADA collateral UTxO for the
|
||||||
|
* later Accept/Reclaim script-spend. PostEscrow is NOT a script spend, so lucid
|
||||||
|
* is otherwise free to consume the dedicated ≥5-ADA collateral UTxO as an
|
||||||
|
* ordinary input — leaving the wallet with no collateral candidate and
|
||||||
|
* stranding the escrow when Accept later runs (the fragmentation bug observed
|
||||||
|
* on back-to-back requests).
|
||||||
|
*
|
||||||
|
* Reserve the SMALLEST pure-ADA UTxO ≥ collateral floor and fund the escrow
|
||||||
|
* from the remaining UTxOs. Returns null (→ default lucid coin selection) when
|
||||||
|
* reservation isn't possible or needed:
|
||||||
|
* - a single UTxO (nothing to reserve),
|
||||||
|
* - no qualifying pure-ADA collateral UTxO, or
|
||||||
|
* - excluding it would underfund the escrow. The large-single-UTxO case is
|
||||||
|
* safe under default selection anyway: the change output is itself a
|
||||||
|
* pure-ADA UTxO well above the collateral floor. */
|
||||||
|
export function reserveCollateralInputs(
|
||||||
|
utxos: LucidUTxO[],
|
||||||
|
lockedLovelace: bigint,
|
||||||
|
): LucidUTxO[] | null {
|
||||||
|
if (utxos.length < 2) return null;
|
||||||
|
const candidates = utxos
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
u.assets.lovelace >= COLLATERAL_MIN_LOVELACE &&
|
||||||
|
Object.keys(u.assets).every((k) => k === "lovelace"),
|
||||||
|
)
|
||||||
|
.sort((a, b) => (a.assets.lovelace < b.assets.lovelace ? -1 : 1));
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
const reserved = candidates[0];
|
||||||
|
const remaining = utxos.filter(
|
||||||
|
(u) => !(u.txHash === reserved.txHash && u.outputIndex === reserved.outputIndex),
|
||||||
|
);
|
||||||
|
if (remaining.length === 0) return null;
|
||||||
|
const remainingLovelace = remaining.reduce((s, u) => s + u.assets.lovelace, 0n);
|
||||||
|
// Cover the locked output + tx fee + a min-change cushion; else fall back.
|
||||||
|
if (remainingLovelace < lockedLovelace + 2_000_000n) return null;
|
||||||
|
return remaining;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PostEscrow ──────────────────────────────────────────────────────
|
// ─── PostEscrow ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function buildLiveTxForEscrow(
|
export async function buildLiveTxForEscrow(
|
||||||
@@ -382,6 +421,12 @@ export async function buildLiveTxForEscrow(
|
|||||||
|
|
||||||
const lockedLovelace = totalLocked < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : totalLocked;
|
const lockedLovelace = totalLocked < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : totalLocked;
|
||||||
|
|
||||||
|
// Preserve a pure-ADA collateral UTxO so the buyer's later Accept/Reclaim
|
||||||
|
// script-spend can find collateral. Without this, PostEscrow coin selection
|
||||||
|
// can consume the 5-ADA collateral UTxO and strand the escrow at Accept.
|
||||||
|
const walletUtxos = await lucid.wallet().getUtxos();
|
||||||
|
const reservedInputs = reserveCollateralInputs(walletUtxos, lockedLovelace);
|
||||||
|
|
||||||
let signed;
|
let signed;
|
||||||
try {
|
try {
|
||||||
const txBuilder = lucid
|
const txBuilder = lucid
|
||||||
@@ -404,7 +449,12 @@ export async function buildLiveTxForEscrow(
|
|||||||
// becomes change. Same fix as Claim/Submit/Accept paths.
|
// becomes change. Same fix as Claim/Submit/Accept paths.
|
||||||
.setMinFee(500_000n);
|
.setMinFee(500_000n);
|
||||||
|
|
||||||
const completed = await txBuilder.complete();
|
// When we can reserve collateral, restrict coin selection to the remaining
|
||||||
|
// (working) UTxOs so the collateral candidate survives for Accept. Else
|
||||||
|
// fall back to lucid's default selection (single/large-UTxO cases are safe).
|
||||||
|
const completed = reservedInputs
|
||||||
|
? await txBuilder.complete({ presetWalletInputs: reservedInputs, localUPLCEval: false })
|
||||||
|
: await txBuilder.complete();
|
||||||
signed = await completed.sign.withWallet().complete();
|
signed = await completed.sign.withWallet().complete();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
rethrowAsTxError(err, "post-escrow build failed");
|
rethrowAsTxError(err, "post-escrow build failed");
|
||||||
|
|||||||
42
packages/shared/src/tx/internal/reserveCollateral.test.ts
Normal file
42
packages/shared/src/tx/internal/reserveCollateral.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { reserveCollateralInputs } from "./liveCbor.js";
|
||||||
|
|
||||||
|
// Minimal LucidUTxO-shaped fixtures (the helper only reads txHash/outputIndex/assets).
|
||||||
|
const pure = (id: string, lovelace: bigint) =>
|
||||||
|
({ txHash: id, outputIndex: 0, assets: { lovelace } }) as never;
|
||||||
|
const mixed = (id: string, lovelace: bigint) =>
|
||||||
|
({ txHash: id, outputIndex: 0, assets: { lovelace, "policy.tok": 1n } }) as never;
|
||||||
|
|
||||||
|
const LOCKED = 2_200_000n; // price + buyer_bond + supplier_bond
|
||||||
|
const ids = (r: ReturnType<typeof reserveCollateralInputs>) =>
|
||||||
|
(r ?? []).map((u: { txHash: string }) => u.txHash);
|
||||||
|
|
||||||
|
describe("reserveCollateralInputs", () => {
|
||||||
|
it("reserves a 5-ADA collateral UTxO and funds from the rest", () => {
|
||||||
|
const r = reserveCollateralInputs([pure("collat", 5_000_000n), pure("work", 6_000_000n)], LOCKED);
|
||||||
|
expect(ids(r)).toEqual(["work"]); // collat (smallest ≥5) reserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves the SMALLEST qualifying candidate", () => {
|
||||||
|
const r = reserveCollateralInputs([pure("big", 9_000_000n), pure("small", 5_000_000n)], LOCKED);
|
||||||
|
expect(ids(r)).toEqual(["big"]); // small reserved as collateral
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only reserves PURE-ADA UTxOs (ignores ones carrying native assets)", () => {
|
||||||
|
const r = reserveCollateralInputs([mixed("mixed", 10_000_000n), pure("purecol", 6_000_000n)], LOCKED);
|
||||||
|
expect(ids(r)).toEqual(["mixed"]); // purecol reserved, fund from the mixed UTxO
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a single UTxO (large change stays as collateral under default selection)", () => {
|
||||||
|
expect(reserveCollateralInputs([pure("only", 12_000_000n)], LOCKED)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when no UTxO qualifies as collateral", () => {
|
||||||
|
expect(reserveCollateralInputs([pure("a", 4_000_000n), pure("b", 4_000_000n)], LOCKED)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when reserving collateral would underfund the escrow", () => {
|
||||||
|
// {5 collateral, 3 working}: working can't cover locked + cushion → fall back.
|
||||||
|
expect(reserveCollateralInputs([pure("collat", 5_000_000n), pure("work", 3_000_000n)], LOCKED)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user