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>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
|
* supplier-walletHealth.test.ts — Tests for the periodic + on-failure
|
|
* wallet auto-consolidation hooks.
|
|
*
|
|
* Coverage:
|
|
* - Ticker calls consolidate when idle, logs already-healthy
|
|
* - Consolidation runs even while session slots are held (wallet ops don't
|
|
* consume session slots) but queues behind a held walletMutex
|
|
* - Failure paths never leave the walletMutex poisoned
|
|
* - On-failure trigger fires consolidate the first time
|
|
* - On-failure trigger debounces back-to-back invocations
|
|
*
|
|
* The consolidate function is injected via deps so the test never touches
|
|
* a real Ogmios.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
import { SupplierState } from "../../supplier/src/state.js";
|
|
import {
|
|
startWalletHealthTicker,
|
|
triggerOnFailureConsolidate,
|
|
_resetWalletHealthForTests,
|
|
type ConsolidateFn,
|
|
} from "../../supplier/src/walletHealth.js";
|
|
|
|
const FAKE_CHAIN = {} as never;
|
|
const FAKE_KEY = {
|
|
privateKeyHex: "0".repeat(64),
|
|
pubKeyHex: "1".repeat(64),
|
|
pubKeyHash: "2".repeat(56),
|
|
address: "addr1_fake",
|
|
} as never;
|
|
const ESCROW_REF = `${"f".repeat(64)}#0`;
|
|
|
|
const healthyResult = {
|
|
txHash: null,
|
|
collateralRef: null,
|
|
workingRef: null,
|
|
reason: "already-healthy" as const,
|
|
inputCount: 2,
|
|
totalLovelaceIn: 200_000_000n,
|
|
collateralOutputLovelace: 5_000_000n,
|
|
workingOutputLovelace: 195_000_000n,
|
|
};
|
|
|
|
const consolidatedResult = {
|
|
txHash: "a".repeat(64),
|
|
collateralRef: `${"a".repeat(64)}#0`,
|
|
workingRef: `${"a".repeat(64)}#1`,
|
|
reason: "consolidate" as const,
|
|
inputCount: 14,
|
|
totalLovelaceIn: 328_530_235n,
|
|
collateralOutputLovelace: 5_000_000n,
|
|
workingOutputLovelace: 322_530_235n,
|
|
};
|
|
|
|
describe("walletHealth — ticker", () => {
|
|
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("calls consolidate when idle and logs already-healthy", async () => {
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
expect(logLines).toContain("already-healthy");
|
|
expect(state.snapshot().status).toBe("free");
|
|
|
|
ticker.stop();
|
|
});
|
|
|
|
it("runs even while a session slot is held (wallet ops don't consume slots)", async () => {
|
|
state.tryAcquire(ESCROW_REF);
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
// The held session slot is untouched.
|
|
expect(state.snapshot().status).toBe("working");
|
|
expect(state.snapshot().activeEscrowRefs).toEqual([ESCROW_REF]);
|
|
|
|
ticker.stop();
|
|
});
|
|
|
|
it("queues behind a held walletMutex instead of racing it", async () => {
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
let releaseChainOp: () => void = () => undefined;
|
|
const chainOp = state.walletMutex.run(
|
|
() => new Promise<void>((resolve) => { releaseChainOp = resolve; }),
|
|
);
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
// The mutex is held by the fake chain op — consolidate must wait.
|
|
expect(consolidate).not.toHaveBeenCalled();
|
|
|
|
releaseChainOp();
|
|
await chainOp;
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
ticker.stop();
|
|
});
|
|
|
|
it("logs success after a real consolidate", async () => {
|
|
consolidate.mockResolvedValue(consolidatedResult);
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
expect(state.snapshot().status).toBe("free");
|
|
expect(logLines.some((l) => l.startsWith("consolidated:"))).toBe(true);
|
|
|
|
ticker.stop();
|
|
});
|
|
|
|
it("does not poison the walletMutex when consolidate throws", async () => {
|
|
consolidate.mockRejectedValue(new Error("ogmios unreachable"));
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
expect(logLines.some((l) => l.includes("consolidate failed"))).toBe(true);
|
|
// The mutex still serves the next wallet op.
|
|
expect(await state.walletMutex.run(async () => "ok")).toBe("ok");
|
|
|
|
ticker.stop();
|
|
});
|
|
|
|
it("stop() prevents further ticks", async () => {
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
|
|
const ticker = startWalletHealthTicker(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ intervalMs: 1_000, log },
|
|
);
|
|
|
|
ticker.stop();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
expect(consolidate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("walletHealth — triggerOnFailureConsolidate", () => {
|
|
let state: SupplierState;
|
|
let logLines: string[];
|
|
let log: (line: string) => void;
|
|
let consolidate: ReturnType<typeof vi.fn> & ConsolidateFn;
|
|
|
|
beforeEach(() => {
|
|
state = new SupplierState();
|
|
_resetWalletHealthForTests(state);
|
|
logLines = [];
|
|
log = (line) => logLines.push(line);
|
|
consolidate = vi.fn() as never;
|
|
});
|
|
|
|
it("fires consolidate on first call", async () => {
|
|
consolidate.mockResolvedValue(consolidatedResult);
|
|
|
|
triggerOnFailureConsolidate(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ log, debounceMs: 60_000 },
|
|
);
|
|
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
expect(state.snapshot().status).toBe("free");
|
|
});
|
|
|
|
it("debounces back-to-back invocations", async () => {
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
|
|
triggerOnFailureConsolidate(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ log, debounceMs: 60_000 },
|
|
);
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
|
|
triggerOnFailureConsolidate(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ log, debounceMs: 60_000 },
|
|
);
|
|
// No second call — second invocation was inside the debounce window.
|
|
expect(consolidate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("runs even when session slots are held (queues on the wallet mutex)", async () => {
|
|
state.tryAcquire(ESCROW_REF);
|
|
consolidate.mockResolvedValue(healthyResult);
|
|
|
|
expect(() =>
|
|
triggerOnFailureConsolidate(
|
|
{ chain: FAKE_CHAIN, state, supplierKey: FAKE_KEY, consolidate },
|
|
{ log, debounceMs: 60_000 },
|
|
),
|
|
).not.toThrow();
|
|
|
|
await vi.waitFor(() => expect(consolidate).toHaveBeenCalledTimes(1));
|
|
// The held session slot is untouched.
|
|
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();
|
|
});
|
|
});
|