fix(wallet): make wallet-health idempotent - structural health predicate + circuit breakers
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
Some checks failed
CI / typecheck + vitest (Node 20) (push) Has been cancelled
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>
This commit is contained in:
@@ -242,3 +242,115 @@ describe("walletHealth — triggerOnFailureConsolidate", () => {
|
||||
expect(state.snapshot().status).toBe("working");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walletHealth — F7 circuit breakers", () => {
|
||||
let state: SupplierState;
|
||||
let logLines: string[];
|
||||
let log: (line: string) => void;
|
||||
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
state = new SupplierState();
|
||||
_resetWalletHealthForTests(state);
|
||||
logLines = [];
|
||||
log = (line) => logLines.push(line);
|
||||
consolidate = vi.fn() as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("halts after 3 consecutive consolidations that never reach healthy", async () => {
|
||||
// The 2026-08-07 pump: every tick consolidates, none lands healthy.
|
||||
consolidate.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(3));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
// Two more ticks: the breaker holds, no further txs.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(3);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("an already-healthy result resets the consecutive counter", async () => {
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValue(healthyResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// 6 ticks: c,c,h,c,c,h — never 3 consecutive, so never halted.
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(6));
|
||||
expect(logLines.some((l) => l.includes("HALT"))).toBe(false);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("halts when the 24h consolidation budget is exhausted", async () => {
|
||||
// Interleave healthy results so the consecutive guard never fires:
|
||||
// c,c,h,c,c,h,c,c -> 6 consolidations inside the window at tick 8.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(healthyResult)
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(8_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(8));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("budget"))).toBe(true);
|
||||
|
||||
// Breaker holds on subsequent ticks.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(consolidate).toHaveBeenCalledTimes(8);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
|
||||
it("failed consolidations neither trip nor reset the breakers", async () => {
|
||||
// Errors spend no fee: they must not count as consolidations, but they
|
||||
// must not clear the consecutive count either.
|
||||
consolidate
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockResolvedValueOnce(consolidatedResult)
|
||||
.mockRejectedValueOnce(new Error("balance too low to consolidate"))
|
||||
.mockResolvedValue(consolidatedResult);
|
||||
|
||||
const ticker = startWalletHealthTicker(
|
||||
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
||||
{ intervalMs: 1_000, log },
|
||||
);
|
||||
|
||||
// c,c,error,c -> the 4th call is the 3rd consecutive consolidation.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(4));
|
||||
expect(logLines.some((l) => l.includes("HALT") && l.includes("consecutive"))).toBe(true);
|
||||
|
||||
ticker.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,14 +64,17 @@ describe("planConsolidate", () => {
|
||||
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.
|
||||
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("consolidate");
|
||||
expect(plan.alreadyHealthy).toBe(false);
|
||||
expect(plan.inputCount).toBe(2);
|
||||
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", () => {
|
||||
@@ -93,3 +96,93 @@ describe("planConsolidate", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user