Files
agents-marketplace/tests/unit/tx-consolidate-wallet.test.ts
FilipBl4gojevic 36c5cfc6be
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
planConsolidate returned already-healthy only for wallets with EXACTLY
2 UTxOs, but buildConsolidateWalletTx lands 3 (collateral + working +
the fee-reserve remainder as change). The shape consolidation produces
can never satisfy its own health check, so every wallet-health tick
re-consolidates and pays the ~0.5 AP3X min fee again. Measured in a
production deployment: a 45s ticker submitted 232 consolidations and
burned ~116 AP3X in about 2.5 idle hours; at the default 600s interval
the same loop leaks ~3 AP3X per hour whenever the wallet is not exactly
2 UTxOs.

Healthy is now structural: a pure-lovelace UTxO >= collateralLovelace
exists, at least 1 AP3X sits outside it, and fragmentation is bounded
(<= 6 UTxOs, new optional maxUtxos param). The health check also runs
BEFORE the low-balance guard, so a wallet that can already run script
transactions is never reshaped or refused. walletHealth gains two
independent circuit breakers - halt after 3 consecutive consolidations
that never reach healthy, halt past 6 consolidations per 24h - so any
future non-convergence costs at most ~1.5 AP3X instead of the whole
wallet, plus a docstring that matches reality.

Tests: the exact post-consolidation-shape regression, one-step
idempotence against the builder output model, candidate boundaries
(native assets, 1,628-lovelace-short), healthy-below-reshape-threshold,
and breaker behavior including error and reset paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:10:45 +02:00

189 lines
6.9 KiB
TypeScript

/**
* 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("accepts a 2-UTxO wallet where the LARGER is the collateral candidate", () => {
// {3, 15}: the 15 qualifies as a pure >=5 candidate and the 3 covers
// fees — this wallet can run script txs as-is. The old exact-2-slot
// convention reshaped it for nothing (0.5 AP3X per pass).
const utxos = [u(3_000_000n), u(15_000_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(true);
expect(plan.collateralOutput).toBe(15_000_000n);
expect(plan.workingOutput).toBe(3_000_000n);
});
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);
});
});
describe("planConsolidate — structural health predicate (F7)", () => {
// The tx buildConsolidateWalletTx actually lands: {collateral 5.0,
// working = total - 5.0 - 2.0 reserve, change = reserve - fee}. The fee
// observed on mainnet is exactly 501,628, so the change is 1,498,372.
const MAINNET_CONSOLIDATE_FEE = 501_628n;
it("REGRESSION: the exact shape consolidation produces is healthy", () => {
// This is the loop that burned 116 AP3X in the 2026-08-07 soak: the
// planner called its own output shape unhealthy and re-consolidated
// every 45s tick. Feed it precisely what the builder lands.
const utxos = [
u(5_000_000n),
u(91_000_000n),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE), // 1,498,372 change
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
expect(plan.alreadyHealthy).toBe(true);
expect(plan.collateralOutput).toBe(5_000_000n);
});
it("is idempotent: applying the builder's output model to any plan yields healthy", () => {
const fragmented = [u(2_400_750n), u(4_100_000n), u(1_679_301n), u(1_679_301n)];
const plan = planConsolidate(fragmented);
expect(plan.alreadyHealthy).toBe(false);
// Model of what buildConsolidateWalletTx lands for this plan.
const afterConsolidation = [
u(plan.collateralOutput),
u(plan.workingOutput),
u(DEFAULT_FEE_RESERVE - MAINNET_CONSOLIDATE_FEE),
];
const replan = planConsolidate(afterConsolidation);
expect(replan.reason).toBe("already-healthy");
expect(replan.alreadyHealthy).toBe(true);
});
it("still consolidates when fragmentation exceeds the max UTxO bound", () => {
// Candidate present, but 7 UTxOs > DEFAULT_MAX_UTXOS (6).
const utxos = [
u(5_000_000n), u(1_100_000n), u(1_100_000n), u(1_100_000n),
u(1_100_000n), u(1_100_000n), u(1_100_000n),
];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("still consolidates when no pure UTxO reaches the collateral threshold", () => {
// The 2026-08-07 batch-stall shape: fragments only, max 4,498,372.
const utxos = [u(4_498_372n), u(3_098_372n), u(1_498_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
expect(plan.alreadyHealthy).toBe(false);
});
it("a UTxO 1,628 lovelace short of collateral is not a candidate", () => {
// Seen live: 4,998,372 (= 5.0 minus a 501,628 fee twice-shuffled).
const utxos = [u(4_998_372n), u(9_000_000n - 4_998_372n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("a UTxO carrying native assets is not a collateral candidate", () => {
const withToken: UtxoLike = {
assets: { lovelace: 6_000_000n, "deadbeef.72737456": 1n },
};
const utxos = [withToken, u(4_000_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("consolidate");
});
it("healthy shape below the reshape threshold is healthy, not an error", () => {
// {5.0, 2.5} = 7.5 total: under the 8.0 reshape minimum but perfectly
// able to run script txs. The old order threw "balance too low" here
// on every tick (1,744 log lines in the soak).
const utxos = [u(5_000_000n), u(2_500_000n)];
const plan = planConsolidate(utxos);
expect(plan.reason).toBe("already-healthy");
});
it("candidate with only dust outside it needs a reshape it cannot afford", () => {
// {5.0, 0.4}: no working money outside the candidate, and 5.4 total is
// below the reshape minimum — loud error, funds needed.
const utxos = [u(5_000_000n), u(400_000n)];
expect(() => planConsolidate(utxos)).toThrow(/balance too low/i);
});
});