wallet: tx:consolidate-wallet CLI + collateral pre-check across script spends

Adds a one-shot operational tx that re-shapes any operator wallet to
{5 AP3X collateral candidate, working remainder} so subsequent Plutus
script-spends (Claim/Submit/Accept/Reclaim/RetireAdvert) find a UTxO
that satisfies lucid's setCollateral=5_000_000n default coin selector.

Symptom this addresses: open-webui mainnet supplier wallet held 8 UTxOs
max 2.40 AP3X, every Accept tx failed at lucid .complete() with the
"wallet must hold a pure-ADA UTxO ≥ 5 ADA" error path. Same fragmentation
risk on the other 4 mainnet operator wallets (one big UTxO surrounded by
dust — first script-spend consumes the big one and leaves nothing usable
as collateral).

Phase 1 — new tx:consolidate-wallet CLI (supplier + buyer):
  - planConsolidate.ts — pure planner; supports consolidate (N>1 → 2),
    split (1 → 2), and already-healthy skip.
  - consolidateWallet.ts — live builder; presetWalletInputs pins all
    UTxOs, two pay.ToAddress outputs (collateral + working).
  - runConsolidateWallet.ts — orchestration (build + submit + awaitTx).
  - supplier/src/cli/consolidate-wallet.ts + buyer mirror.
  - monitor-wallets.ts — add supplier-tts@vector-marketplace (was
    misclassified as 3rd-party).

Phase 2 — defensive pre-check in the 5 script-spend builders:
  - assertCollateralCandidate(utxos) fires at the top of
    buildSpendOpenOrClaimedTx (Claim/Submit), buildLiveTxForAccept,
    buildLiveTxForReclaim, buildLiveTxForRetireAdvert. Throws a
    TxConstructionError("collateral_required") pointing at
    tx:consolidate-wallet instead of waiting for lucid's deep failure.

Tests: 12 new passing (planConsolidate edge cases + assert helper).
Pre-existing lucid 0.4.30 WASM "unreachable" failures in tx-*-live.test.ts
fixtures are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-05-20 10:59:11 +02:00
parent 87e126b22c
commit 972c0f9b89
12 changed files with 1099 additions and 2 deletions

View File

@@ -292,6 +292,34 @@ function chainUtxoToLucidScriptInput(
};
}
/** Lucid 0.4.30's default `setCollateral` is 5_000_000n; its collateral
* selector needs a pure-AP3X UTxO ≥ this threshold. Mirror that floor
* here so we can fail fast with an actionable hint instead of waiting
* for lucid's deep "wallet must hold a pure-ADA UTxO ≥ 5 ADA" error. */
const COLLATERAL_MIN_LOVELACE = 5_000_000n;
/** Pre-check: assert the wallet has a UTxO that can serve as collateral.
* Called at the top of each script-spend builder so a fragmented wallet
* surfaces a clear "run tx:consolidate-wallet" message instead of the
* deeper lucid error caught downstream by rethrowAsTxError. */
export function assertCollateralCandidate(utxos: LucidUTxO[]): void {
for (const u of utxos) {
if (u.assets.lovelace < COLLATERAL_MIN_LOVELACE) continue;
const onlyLovelace = Object.keys(u.assets).every((k) => k === "lovelace");
if (onlyLovelace) return;
}
const maxLovelace = utxos.reduce(
(m, u) => (u.assets.lovelace > m ? u.assets.lovelace : m),
0n,
);
throw new TxConstructionError(
"collateral_required",
`wallet has no UTxO ≥ ${COLLATERAL_MIN_LOVELACE} lovelace for collateral ` +
`(have ${utxos.length} UTxO(s), max ${maxLovelace} lovelace). ` +
`run \`pnpm --filter @marketplace/{supplier,buyer} tx:consolidate-wallet\` to re-shape the wallet.`,
);
}
/** TxConstructionError wrapping arbitrary lucid failures, surfacing
* collateral mismatches with a stable substring so tests can assert.
*
@@ -446,6 +474,7 @@ async function buildSpendOpenOrClaimedTx(opts: {
// that held only 5 AP3X total; in production the real wallet has many UTxOs
// and the ledger rejects unknown references. ARCHITECTURE.md §9 #14 cleanup.
const realWalletUtxos = await lucid.wallet().getUtxos();
assertCollateralCandidate(realWalletUtxos);
let signed;
try {
@@ -595,6 +624,7 @@ export async function buildLiveTxForAccept(params: LiveAcceptParams): Promise<Bu
// Real wallet UTxOs only; the test-side synthetic padding input is removed
// (it caused unknownOutputReferences errors against the real ledger).
const realWalletUtxos = await lucid.wallet().getUtxos();
assertCollateralCandidate(realWalletUtxos);
const presetWalletInputs = realWalletUtxos;
let signed;
@@ -660,6 +690,7 @@ export async function buildLiveTxForReclaim(params: LiveReclaimParams): Promise<
const buyerAddress = pkhToEnterpriseAddress(params.datum.buyer_pkh, MAINNET_ID);
const realWalletUtxos = await lucid.wallet().getUtxos();
assertCollateralCandidate(realWalletUtxos);
const presetWalletInputs = realWalletUtxos;
let signed;
@@ -785,6 +816,7 @@ export async function buildLiveTxForRetireAdvert(
advertUtxo.lovelace < MIN_UTXO_LOVELACE ? MIN_UTXO_LOVELACE : advertUtxo.lovelace;
const presetWalletInputs = await lucid.wallet().getUtxos();
assertCollateralCandidate(presetWalletInputs);
let signed;
try {

View File

@@ -16,3 +16,25 @@ export type {
PublishRefScriptsBuildResult,
} from "./internal/publishReferenceScripts.js";
export { buildLiveTxForPublishReferenceScripts } from "./internal/publishReferenceScripts.js";
// ── Wallet consolidate (one-shot operational tx; live-only) ─────────────
export type {
ConsolidateWalletParams,
ConsolidateWalletBuildResult,
} from "./wallet/consolidateWallet.js";
export { buildConsolidateWalletTx } from "./wallet/consolidateWallet.js";
export type {
ConsolidateReason,
PlannedConsolidate,
UtxoLike,
} from "./wallet/planConsolidate.js";
export {
DEFAULT_COLLATERAL_LOVELACE,
DEFAULT_FEE_RESERVE,
planConsolidate,
} from "./wallet/planConsolidate.js";
export type {
ConsolidateWalletFlowParams,
ConsolidateWalletFlowResult,
} from "./wallet/runConsolidateWallet.js";
export { runConsolidateWallet } from "./wallet/runConsolidateWallet.js";

View File

@@ -0,0 +1,123 @@
/**
* consolidateWallet.ts — wallet re-shaping tx builder.
*
* Re-shapes any wallet to {collateral UTxO (default 5 AP3X), working UTxO
* (remainder)} so lucid-evolution's coin selection for Plutus-script spends
* (Claim/Submit/Accept/Reclaim/RetireAdvert) reliably finds a pure-AP3X
* UTxO ≥ setCollateral (default 5_000_000n) as collateral candidate,
* distinct from the fee/change input.
*
* Handles two modes via the same tx shape (driven by planConsolidate):
* - consolidate: N > 1 fragmented UTxOs → 2 outputs.
* - split: 1 large UTxO → 2 outputs.
*
* No script witness, no datum, no native assets. One output to wallet vkh
* with exactly `collateralLovelace`, one output to wallet vkh with the
* remainder (lucid balances residual fee from the working output via
* change-balancing on top of the explicit `lovelace: workingOutput` value).
*
* Operational use only. Invoke via supplier/src/cli/consolidate-wallet.ts
* or buyer/src/cli/consolidate-wallet.ts.
*/
import type { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
import type { BuildResult, WalletKey } from "../types.js";
import { TxConstructionError } from "../types.js";
import { OgmiosLucidProvider } from "../../chain/OgmiosLucidProvider.js";
import { createLucidContext } from "../internal/lucidContext.js";
import {
DEFAULT_COLLATERAL_LOVELACE,
planConsolidate,
type ConsolidateReason,
} from "./planConsolidate.js";
export interface ConsolidateWalletParams {
chain: LiveOgmiosProvider;
walletKey: WalletKey;
/** Lovelace value for output #0 (the collateral candidate).
* Default 5_000_000n (matches lucid's default `setCollateral`). */
collateralLovelace?: bigint;
}
export interface ConsolidateWalletBuildResult extends BuildResult {
inputCount: number;
totalLovelaceIn: bigint;
collateralOutputLovelace: bigint;
workingOutputLovelace: bigint;
reason: ConsolidateReason;
/** True when the wallet was already at {≥collateral, working} — no tx submitted. */
alreadyHealthy: boolean;
}
export async function buildConsolidateWalletTx(
params: ConsolidateWalletParams,
): Promise<ConsolidateWalletBuildResult> {
const { chain, walletKey } = params;
const collateralLovelace =
params.collateralLovelace ?? DEFAULT_COLLATERAL_LOVELACE;
const provider = new OgmiosLucidProvider({
ogmiosUrl: chain.url,
fetch: chain.fetchImpl,
});
const { lucid } = await createLucidContext(
provider,
walletKey,
{ networkId: 1, systemStartUnix: 0, slotLengthMs: 1000 },
{ usePresetProtocolParameters: true },
);
const realWalletUtxos = await lucid.wallet().getUtxos();
const plan = planConsolidate(realWalletUtxos, collateralLovelace);
if (plan.alreadyHealthy) {
return {
txCborHex: "",
expectedTxHash: "",
inputCount: plan.inputCount,
totalLovelaceIn: plan.totalLovelaceIn,
collateralOutputLovelace: plan.collateralOutput,
workingOutputLovelace: plan.workingOutput,
reason: plan.reason,
alreadyHealthy: true,
};
}
let signed;
try {
const txBuilder = lucid
.newTx()
.pay.ToAddress(walletKey.address, { lovelace: collateralLovelace })
.pay.ToAddress(walletKey.address, { lovelace: plan.workingOutput })
.addSignerKey(walletKey.pubKeyHash)
// 500K floor matches commit 4f1ec9a's setMinFee bump across live builders.
// The workingOutput already deducts a 2M fee reserve in planConsolidate;
// lucid balances the actual fee out of that reserve via change.
.setMinFee(500_000n);
const completed = await txBuilder.complete({
presetWalletInputs: realWalletUtxos,
localUPLCEval: false,
});
signed = await completed.sign.withWallet().complete();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new TxConstructionError("consolidate_wallet_build_failed", msg);
}
const txCborHex = signed.toCBOR();
const expectedTxHash = signed.toHash();
await chain.submitTx(txCborHex);
return {
txCborHex,
expectedTxHash,
inputCount: plan.inputCount,
totalLovelaceIn: plan.totalLovelaceIn,
collateralOutputLovelace: collateralLovelace,
workingOutputLovelace: plan.workingOutput,
reason: plan.reason,
alreadyHealthy: false,
};
}

View File

@@ -0,0 +1,81 @@
/**
* planConsolidate.ts — pure pre-flight planner for the wallet-consolidate flow.
*
* Given a wallet's current UTxO set + a target collateral lovelace, decide
* whether to consolidate (many → 2), split (1 → 2), or skip (already at
* {≥collateral, working}). The same downstream tx-builder handles
* consolidate and split — only the "reason" tag differs for logging.
*
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
*/
export interface UtxoLike {
assets: { lovelace?: bigint } & Record<string, bigint | undefined>;
}
export type ConsolidateReason = "consolidate" | "split" | "already-healthy";
export interface PlannedConsolidate {
inputCount: number;
totalLovelaceIn: bigint;
collateralOutput: bigint;
workingOutput: bigint;
alreadyHealthy: boolean;
reason: ConsolidateReason;
}
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
export const DEFAULT_FEE_RESERVE = 2_000_000n;
const MIN_WORKING_LOVELACE = 1_000_000n;
export function planConsolidate(
utxos: UtxoLike[],
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
feeReserve: bigint = DEFAULT_FEE_RESERVE,
): PlannedConsolidate {
if (utxos.length === 0) {
throw new Error("wallet has no UTxOs — fund the address first");
}
const totalLovelaceIn = utxos.reduce(
(acc, u) => acc + (u.assets.lovelace ?? 0n),
0n,
);
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
if (totalLovelaceIn < required) {
throw new Error(
`balance too low to consolidate: have ${totalLovelaceIn} lovelace, ` +
`need at least ${required} (= ${collateralLovelace} collateral + ` +
`${feeReserve} fee reserve + ${MIN_WORKING_LOVELACE} min working)`,
);
}
if (utxos.length === 2) {
const lov0 = utxos[0].assets.lovelace ?? 0n;
const lov1 = utxos[1].assets.lovelace ?? 0n;
const smaller = lov0 < lov1 ? lov0 : lov1;
const larger = lov0 < lov1 ? lov1 : lov0;
if (smaller >= collateralLovelace) {
return {
inputCount: 2,
totalLovelaceIn,
collateralOutput: smaller,
workingOutput: larger,
alreadyHealthy: true,
reason: "already-healthy",
};
}
}
const workingOutput = totalLovelaceIn - collateralLovelace - feeReserve;
return {
inputCount: utxos.length,
totalLovelaceIn,
collateralOutput: collateralLovelace,
workingOutput,
alreadyHealthy: false,
reason: utxos.length === 1 ? "split" : "consolidate",
};
}

View File

@@ -0,0 +1,112 @@
/**
* runConsolidateWallet.ts — orchestration layer for the consolidate-wallet
* CLIs (supplier + buyer). Builds + submits the consolidate tx, awaits
* on-chain confirmation, returns the resulting refs.
*
* Live-only (the consolidate builder is live-only).
*/
import type { ChainProvider } from "../../chain/ChainProvider.js";
import { LiveOgmiosProvider } from "../../chain/LiveOgmiosProvider.js";
import { TxConstructionError, type WalletKey } from "../types.js";
import {
buildConsolidateWalletTx,
type ConsolidateWalletBuildResult,
} from "./consolidateWallet.js";
import type { ConsolidateReason } from "./planConsolidate.js";
export interface ConsolidateWalletFlowParams {
chain: ChainProvider;
walletKey: WalletKey;
collateralLovelace: bigint;
awaitTimeoutMs?: number;
log?: (line: string) => void;
}
export interface ConsolidateWalletFlowResult {
/** Null when `reason === "already-healthy"` (no tx submitted). */
txHash: string | null;
collateralRef: string | null;
workingRef: string | null;
reason: ConsolidateReason;
inputCount: number;
totalLovelaceIn: bigint;
collateralOutputLovelace: bigint;
workingOutputLovelace: bigint;
}
export async function runConsolidateWallet(
params: ConsolidateWalletFlowParams,
): Promise<ConsolidateWalletFlowResult> {
const { chain, walletKey, collateralLovelace } = params;
const writeLog =
params.log ??
((line: string) => {
// eslint-disable-next-line no-console
console.log(line);
});
const timeoutMs = params.awaitTimeoutMs ?? 120_000;
if (!(chain instanceof LiveOgmiosProvider)) {
throw new TxConstructionError(
"consolidate_wallet_requires_live_chain",
"consolidate-wallet does not support mock mode — needs a real Ogmios endpoint",
);
}
writeLog(`consolidating wallet ${walletKey.address}`);
writeLog(`target collateral output: ${collateralLovelace} lovelace`);
const built: ConsolidateWalletBuildResult = await buildConsolidateWalletTx({
chain,
walletKey,
collateralLovelace,
});
writeLog(
`plan: ${built.reason} — ${built.inputCount} input(s), ` +
`total ${built.totalLovelaceIn} lovelace → ` +
`collateral ${built.collateralOutputLovelace} + working ${built.workingOutputLovelace}`,
);
if (built.alreadyHealthy) {
writeLog("wallet already healthy — no tx submitted");
return {
txHash: null,
collateralRef: null,
workingRef: null,
reason: built.reason,
inputCount: built.inputCount,
totalLovelaceIn: built.totalLovelaceIn,
collateralOutputLovelace: built.collateralOutputLovelace,
workingOutputLovelace: built.workingOutputLovelace,
};
}
writeLog(`submitted tx ${built.expectedTxHash}`);
writeLog("awaiting confirmation");
try {
await chain.awaitTx(built.expectedTxHash, timeoutMs);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(
reason.includes(built.expectedTxHash)
? reason
: `awaitTx failed for txHash ${built.expectedTxHash}: ${reason}`,
);
}
writeLog("confirmed");
return {
txHash: built.expectedTxHash,
collateralRef: `${built.expectedTxHash}#0`,
workingRef: `${built.expectedTxHash}#1`,
reason: built.reason,
inputCount: built.inputCount,
totalLovelaceIn: built.totalLovelaceIn,
collateralOutputLovelace: built.collateralOutputLovelace,
workingOutputLovelace: built.workingOutputLovelace,
};
}