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:
73
tests/unit/tx-assert-collateral.test.ts
Normal file
73
tests/unit/tx-assert-collateral.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* tx-assert-collateral.test.ts — unit tests for the assertCollateralCandidate
|
||||
* pre-check that fires from each script-spend builder.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { assertCollateralCandidate } from "../../packages/shared/src/tx/internal/liveCbor.js";
|
||||
import { TxConstructionError } from "../../packages/shared/src/tx/types.js";
|
||||
|
||||
type FakeLucidUtxo = { assets: Record<string, bigint> };
|
||||
|
||||
function lov(n: bigint): FakeLucidUtxo {
|
||||
return { assets: { lovelace: n } };
|
||||
}
|
||||
|
||||
describe("assertCollateralCandidate", () => {
|
||||
it("passes when one UTxO is ≥ 5 AP3X pure-AP3X", () => {
|
||||
const utxos = [lov(1_000_000n), lov(5_000_000n), lov(2_400_000n)];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes when one UTxO is much larger than 5 AP3X", () => {
|
||||
const utxos = [lov(1_000_000_000n)];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when the wallet has no UTxO ≥ 5 AP3X", () => {
|
||||
const utxos = [
|
||||
lov(2_400_750n),
|
||||
lov(1_679_301n),
|
||||
lov(1_679_301n),
|
||||
lov(1_679_301n),
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let caught: unknown;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assertCollateralCandidate(utxos as any);
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(TxConstructionError);
|
||||
expect((caught as TxConstructionError).reason).toBe("collateral_required");
|
||||
expect((caught as TxConstructionError).message).toMatch(
|
||||
/tx:consolidate-wallet/,
|
||||
);
|
||||
expect((caught as TxConstructionError).message).toMatch(/4 UTxO/);
|
||||
expect((caught as TxConstructionError).message).toMatch(/2400750/);
|
||||
});
|
||||
|
||||
it("throws when the wallet is empty", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate([] as any)).toThrow(
|
||||
TxConstructionError,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips UTxOs that carry native assets even when ≥ 5 AP3X", () => {
|
||||
// On Vector L2 the operator wallets are pure-AP3X, but the safety net
|
||||
// catches a hypothetical UTxO with native tokens — lucid's collateral
|
||||
// selector rejects those.
|
||||
const utxos = [
|
||||
{ assets: { lovelace: 10_000_000n, ["d3.tok"]: 1n } },
|
||||
lov(1_000_000n),
|
||||
];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(() => assertCollateralCandidate(utxos as any)).toThrow(
|
||||
/collateral/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
95
tests/unit/tx-consolidate-wallet.test.ts
Normal file
95
tests/unit/tx-consolidate-wallet.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* tx-consolidate-wallet.test.ts — pure unit tests for the consolidate
|
||||
* planner. Drives planConsolidate directly without lucid / WASM.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
planConsolidate,
|
||||
DEFAULT_COLLATERAL_LOVELACE,
|
||||
DEFAULT_FEE_RESERVE,
|
||||
type UtxoLike,
|
||||
} from "../../packages/shared/src/tx/wallet/planConsolidate.js";
|
||||
|
||||
function u(lovelace: bigint): UtxoLike {
|
||||
return { assets: { lovelace } };
|
||||
}
|
||||
|
||||
describe("planConsolidate", () => {
|
||||
it("consolidates a fragmented wallet (8 UTxOs, max 2.4 AP3X)", () => {
|
||||
const utxos: UtxoLike[] = [
|
||||
u(2_400_750n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
u(1_679_301n),
|
||||
];
|
||||
const total = utxos.reduce((s, x) => s + x.assets.lovelace, 0n);
|
||||
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(8);
|
||||
expect(plan.totalLovelaceIn).toBe(total);
|
||||
expect(plan.collateralOutput).toBe(DEFAULT_COLLATERAL_LOVELACE);
|
||||
expect(plan.workingOutput).toBe(
|
||||
total - DEFAULT_COLLATERAL_LOVELACE - DEFAULT_FEE_RESERVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("splits a single large UTxO into 2 outputs", () => {
|
||||
const utxos = [u(17_500_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("split");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(1);
|
||||
expect(plan.collateralOutput).toBe(DEFAULT_COLLATERAL_LOVELACE);
|
||||
expect(plan.workingOutput).toBe(
|
||||
17_500_000n - DEFAULT_COLLATERAL_LOVELACE - DEFAULT_FEE_RESERVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a wallet already in {collateral, working} shape", () => {
|
||||
const utxos = [u(5_000_000n), u(8_650_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("already-healthy");
|
||||
expect(plan.alreadyHealthy).toBe(true);
|
||||
expect(plan.collateralOutput).toBe(5_000_000n);
|
||||
expect(plan.workingOutput).toBe(8_650_000n);
|
||||
});
|
||||
|
||||
it("consolidates a 2-UTxO wallet where the smaller is below collateral threshold", () => {
|
||||
// Both fit budget but smaller (3 AP3X) is below 5 AP3X — needs reshape.
|
||||
const utxos = [u(3_000_000n), u(15_000_000n)];
|
||||
const plan = planConsolidate(utxos);
|
||||
|
||||
expect(plan.reason).toBe("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(2);
|
||||
});
|
||||
|
||||
it("throws on 0 UTxOs", () => {
|
||||
expect(() => planConsolidate([])).toThrow(/no UTxOs/i);
|
||||
});
|
||||
|
||||
it("throws on balance below threshold", () => {
|
||||
// 4 AP3X total — under 5 + 2 + 1 = 8 AP3X required minimum.
|
||||
const utxos = [u(2_000_000n), u(2_000_000n)];
|
||||
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
|
||||
});
|
||||
|
||||
it("respects custom collateralLovelace", () => {
|
||||
const utxos = [u(20_000_000n)];
|
||||
const plan = planConsolidate(utxos, 10_000_000n);
|
||||
|
||||
expect(plan.reason).toBe("split");
|
||||
expect(plan.collateralOutput).toBe(10_000_000n);
|
||||
expect(plan.workingOutput).toBe(20_000_000n - 10_000_000n - DEFAULT_FEE_RESERVE);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user