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:
@@ -2,9 +2,22 @@
|
||||
* 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.
|
||||
* whether the wallet needs reshaping or is already usable for script spends.
|
||||
*
|
||||
* Healthy is a STRUCTURAL predicate, not a UTxO count (F7, 2026-08-08):
|
||||
* (a) some pure-lovelace UTxO >= collateralLovelace exists (the collateral
|
||||
* candidate lucid's setCollateral needs), AND
|
||||
* (b) at least MIN_WORKING_LOVELACE sits OUTSIDE that candidate (fee and
|
||||
* spend money that is not the candidate), AND
|
||||
* (c) fragmentation is bounded (utxos.length <= maxUtxos).
|
||||
*
|
||||
* The earlier revision returned `already-healthy` only for EXACTLY 2 UTxOs.
|
||||
* The consolidate tx itself lands 3 (collateral + working + the fee-reserve
|
||||
* remainder as change), so the shape consolidation produced could never
|
||||
* satisfy its own health check — the wallet-health ticker re-consolidated
|
||||
* every tick and burned 0.5 AP3X per pass (116 AP3X in one 24h soak).
|
||||
* The health check also runs BEFORE the low-balance guard now: a wallet that
|
||||
* can already transact must never be told "balance too low to consolidate".
|
||||
*
|
||||
* No lucid / WASM deps so this is unit-testable from a vanilla vitest.
|
||||
*/
|
||||
@@ -26,12 +39,24 @@ export interface PlannedConsolidate {
|
||||
|
||||
export const DEFAULT_COLLATERAL_LOVELACE = 5_000_000n;
|
||||
export const DEFAULT_FEE_RESERVE = 2_000_000n;
|
||||
/** Reshape only when fragmentation passes this; receipt/change UTxOs
|
||||
* accumulating between jobs are fine as long as a candidate exists. */
|
||||
export const DEFAULT_MAX_UTXOS = 6;
|
||||
const MIN_WORKING_LOVELACE = 1_000_000n;
|
||||
|
||||
/** Lovelace of a UTxO iff it carries no native assets; null otherwise. */
|
||||
function pureLovelace(u: UtxoLike): bigint | null {
|
||||
for (const [unit, amount] of Object.entries(u.assets)) {
|
||||
if (unit !== "lovelace" && (amount ?? 0n) > 0n) return null;
|
||||
}
|
||||
return u.assets.lovelace ?? 0n;
|
||||
}
|
||||
|
||||
export function planConsolidate(
|
||||
utxos: UtxoLike[],
|
||||
collateralLovelace: bigint = DEFAULT_COLLATERAL_LOVELACE,
|
||||
feeReserve: bigint = DEFAULT_FEE_RESERVE,
|
||||
maxUtxos: number = DEFAULT_MAX_UTXOS,
|
||||
): PlannedConsolidate {
|
||||
if (utxos.length === 0) {
|
||||
throw new Error("wallet has no UTxOs — fund the address first");
|
||||
@@ -42,6 +67,32 @@ export function planConsolidate(
|
||||
0n,
|
||||
);
|
||||
|
||||
// Structural health check first — reshaping a usable wallet wastes a fee.
|
||||
if (utxos.length <= maxUtxos) {
|
||||
// Smallest qualifying candidate: leave larger UTxOs as working funds.
|
||||
let candidate: bigint | null = null;
|
||||
for (const u of utxos) {
|
||||
const pure = pureLovelace(u);
|
||||
if (pure !== null && pure >= collateralLovelace) {
|
||||
if (candidate === null || pure < candidate) candidate = pure;
|
||||
}
|
||||
}
|
||||
if (candidate !== null) {
|
||||
const workingOutside = totalLovelaceIn - candidate;
|
||||
if (workingOutside >= MIN_WORKING_LOVELACE) {
|
||||
return {
|
||||
inputCount: utxos.length,
|
||||
totalLovelaceIn,
|
||||
collateralOutput: candidate,
|
||||
workingOutput: workingOutside,
|
||||
alreadyHealthy: true,
|
||||
reason: "already-healthy",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reshape needed — only an actual reshape has a funds threshold.
|
||||
const required = collateralLovelace + feeReserve + MIN_WORKING_LOVELACE;
|
||||
if (totalLovelaceIn < required) {
|
||||
throw new Error(
|
||||
@@ -51,23 +102,6 @@ export function planConsolidate(
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user