Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-hoops-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@exactly/lib": patch
---

✨ auditor: add optional haircuts per market
44 changes: 39 additions & 5 deletions src/auditor/accountLiquidity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import MAX_UINT256 from "../fixed-point-math/MAX_UINT256.js";
import WAD from "../fixed-point-math/WAD.js";
import divWad from "../fixed-point-math/divWad.js";
import divWadUp from "../fixed-point-math/divWadUp.js";
import mulDiv from "../fixed-point-math/mulDiv.js";
Expand All @@ -7,13 +9,15 @@ import mulWad from "../fixed-point-math/mulWad.js";
export default function accountLiquidity(
data: AccountLiquidityData,
timestamp = Math.floor(Date.now() / 1000),
haircuts?: Haircuts,
): {
adjCollateral: bigint;
adjDebt: bigint;
} {
let adjCollateral = 0n;
let adjDebt = 0n;
for (const {
market,
isCollateral,
floatingBorrowAssets,
floatingDepositAssets,
Expand All @@ -24,15 +28,22 @@ export default function accountLiquidity(
usdPrice,
} of data) {
const baseUnit = 10n ** BigInt(decimals);
if (isCollateral) adjCollateral += adjustCollateral(floatingDepositAssets, usdPrice, baseUnit, adjustFactor);
const haircut = marketHaircut(haircuts, market);
if (haircut && (haircut < 0n || haircut > WAD)) throw new Error("haircut outside [0, 1]");

if (isCollateral) {
const adjusted = adjustCollateral(floatingDepositAssets, usdPrice, baseUnit, adjustFactor);
adjCollateral += haircut ? mulWad(adjusted, WAD - haircut) : adjusted;
}

let totalDebt = floatingBorrowAssets;
for (const { position, maturity } of fixedBorrowPositions) {
const positionAssets = position.principal + position.fee;
totalDebt += positionAssets;
if (timestamp > maturity) totalDebt += mulWad(positionAssets, (BigInt(timestamp) - maturity) * penaltyRate);
}
adjDebt += adjustDebt(totalDebt, usdPrice, baseUnit, adjustFactor);
const debt = adjustDebt(totalDebt, usdPrice, baseUnit, adjustFactor);
adjDebt += haircut ? (haircut === WAD && debt !== 0n ? MAX_UINT256 : divWadUp(debt, WAD - haircut)) : debt;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return zero debt for fully haircut zero balances

When a market has a 100% haircut and no borrow balance or fixed borrow positions, debt is 0n, so this falls through to divWadUp(0n, WAD - haircut) with a zero denominator. That makes otherwise valid collateral-only or unused markets crash in accountLiquidity (and therefore healthFactor, borrowLimit, and withdrawLimit) as soon as callers supply { [market]: WAD }; zero debt under a full haircut should contribute 0n rather than divide by zero.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't model infinite debt as a finite cap

When a fully haircut market has any debt, this substitutes the division-by-zero result with MAX_UINT256, but the rest of these helpers use unbounded bigint sums for collateral across markets. With enough collateral in other markets (for example two large collateral markets whose adjusted collateral totals above MAX_UINT256), healthFactor can still report a healthy account and borrowLimit can allow more borrowing even though debt divided by zero should make the account insolvent; represent this case as an explicit infinite/insolvent state rather than a finite additive value.

Useful? React with 👍 / 👎.

}

return { adjCollateral, adjDebt };
Expand All @@ -56,14 +67,37 @@ export function normalizeCollateral(
usdPrice: bigint,
baseUnit: bigint,
adjustFactor: bigint,
haircut?: bigint,
) {
if (haircut === WAD) return adjustedCollateral ? MAX_UINT256 : 0n;
return divWad(
mulDiv(haircut ? divWad(adjustedCollateral, WAD - haircut) : adjustedCollateral, baseUnit, usdPrice),
Comment thread
cruzdanilo marked this conversation as resolved.
Comment thread
cruzdanilo marked this conversation as resolved.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid over-withdrawing after haircut rounding

When a partially haircut market has prices or adjust factors that cause truncation, this floors the unhaircuted adjusted amount before converting back to raw assets, but accountLiquidity later floors the haircuted collateral contribution again. In those cases withdrawLimit can return a raw amount whose removal exceeds adjCollateral - minAdjCollateral by one adjusted unit and leaves the account just below the requested target health factor (for example with a 50% haircut and non-1e18 price/adjust factor), so the haircut path needs to round conservatively or verify the post-withdrawal contribution.

Useful? React with 👍 / 👎.

adjustFactor,
);
}

export function normalizeDebt(
adjustedDebt: bigint,
usdPrice: bigint,
baseUnit: bigint,
adjustFactor: bigint,
haircut?: bigint,
) {
return divWad(mulDiv(adjustedCollateral, baseUnit, usdPrice), adjustFactor);
return mulDiv(mulWad(haircut ? mulWad(adjustedDebt, WAD - haircut) : adjustedDebt, adjustFactor), baseUnit, usdPrice);
}

export function normalizeDebt(adjustedDebt: bigint, usdPrice: bigint, baseUnit: bigint, adjustFactor: bigint) {
return mulDiv(mulWad(adjustedDebt, adjustFactor), baseUnit, usdPrice);
export function marketHaircut(haircuts: Haircuts | undefined, market: string): bigint {
if (!haircuts) return 0n;

const exact = haircuts[market];
if (exact !== undefined) return exact;

const lower = market.toLowerCase();
return haircuts[lower] ?? Object.entries(haircuts).find(([key]) => key.toLowerCase() === lower)?.[1] ?? 0n;
}

export type Haircuts = Readonly<Record<string, bigint>>;

export type AccountLiquidityData = readonly {
market: string;
decimals: number;
Expand Down
18 changes: 15 additions & 3 deletions src/auditor/borrowLimit.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import accountLiquidity, { normalizeDebt, type AccountLiquidityData } from "./accountLiquidity.js";
import accountLiquidity, {
marketHaircut,
normalizeDebt,
type AccountLiquidityData,
type Haircuts,
} from "./accountLiquidity.js";
import WAD from "../fixed-point-math/WAD.js";
import divWad from "../fixed-point-math/divWad.js";

Expand All @@ -7,8 +12,9 @@ export default function borrowLimit(
market: string,
targetHealthFactor = (WAD * 105n) / 100n,
timestamp?: number,
haircuts?: Haircuts,
): bigint {
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp);
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp, haircuts);
const marketData = data.find(({ market: m }) => m.toLowerCase() === market.toLowerCase());
if (!marketData) throw new Error("market not found");

Expand All @@ -18,5 +24,11 @@ export default function borrowLimit(
if (adjDebt >= maxAdjDebt) return 0n;

const maxExtraDebt = maxAdjDebt - adjDebt;
return normalizeDebt(maxExtraDebt, usdPrice, 10n ** BigInt(decimals), adjustFactor);
return normalizeDebt(
maxExtraDebt,
usdPrice,
10n ** BigInt(decimals),
adjustFactor,
marketHaircut(haircuts, marketData.market),
);
}
6 changes: 3 additions & 3 deletions src/auditor/healthFactor.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import accountLiquidity, { type AccountLiquidityData } from "./accountLiquidity.js";
import accountLiquidity, { type AccountLiquidityData, type Haircuts } from "./accountLiquidity.js";
import MAX_UINT256 from "../fixed-point-math/MAX_UINT256.js";
import divWad from "../fixed-point-math/divWad.js";

export default function healthFactor(data: AccountLiquidityData, timestamp?: number): bigint {
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp);
export default function healthFactor(data: AccountLiquidityData, timestamp?: number, haircuts?: Haircuts): bigint {
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp, haircuts);
return adjDebt ? divWad(adjCollateral, adjDebt) : MAX_UINT256;
}
21 changes: 15 additions & 6 deletions src/auditor/withdrawLimit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { AccountLiquidityData } from "./accountLiquidity.js";
import accountLiquidity, { adjustCollateral, normalizeCollateral } from "./accountLiquidity.js";
import accountLiquidity, {
adjustCollateral,
marketHaircut,
normalizeCollateral,
type AccountLiquidityData,
type Haircuts,
} from "./accountLiquidity.js";
import WAD from "../fixed-point-math/WAD.js";
import mulWad from "../fixed-point-math/mulWad.js";

Expand All @@ -8,8 +13,9 @@ export default function withdrawLimit(
market: string,
targetHealthFactor = (WAD * 105n) / 100n,
timestamp?: number,
haircuts?: Haircuts,
): bigint {
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp);
const { adjCollateral, adjDebt } = accountLiquidity(data, timestamp, haircuts);
const marketData = data.find(({ market: m }) => m.toLowerCase() === market.toLowerCase());
if (!marketData) throw new Error("market not found");

Expand All @@ -21,9 +27,12 @@ export default function withdrawLimit(

if (adjCollateral <= minAdjCollateral) return 0n;

const adjCollateralMarket = adjustCollateral(floatingDepositAssets, usdPrice, baseUnit, adjustFactor);
if (adjCollateral - adjCollateralMarket >= minAdjCollateral) return floatingDepositAssets;
const adjusted = adjustCollateral(floatingDepositAssets, usdPrice, baseUnit, adjustFactor);
const haircut = marketHaircut(haircuts, marketData.market);
if (adjCollateral - (haircut ? mulWad(adjusted, WAD - haircut) : adjusted) >= minAdjCollateral) {
return floatingDepositAssets;
}

const withdrawable = adjCollateral - minAdjCollateral;
return normalizeCollateral(withdrawable, usdPrice, baseUnit, adjustFactor);
return normalizeCollateral(withdrawable, usdPrice, baseUnit, adjustFactor, haircut);
}
51 changes: 51 additions & 0 deletions test/auditor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,57 @@ describe("with static data", () => {

expect(healthFactor(exaBorrowedTooMuch, 0)).toBeLessThan(targetHF);
});

it("account liquidity with haircuts", () => {
const { collateral, debt } = exactlyAccountLiquidity();
const haircut = parseUnits("0.1", 18);
const remaining = WAD - haircut;
const usdcAdjCollateral = mulDiv(parseUnits("10000", 6), parseUnits("0.91", 18), 10n ** 6n);
const usdcAdjDebt = divWadUp(parseUnits("3000", 18), parseUnits("0.91", 18));
const { adjCollateral, adjDebt } = accountLiquidity(exactly, 0, { [exactly[0].market]: haircut });

expect(adjCollateral).toBe(collateral - usdcAdjCollateral + mulWad(usdcAdjCollateral, remaining));
expect(adjDebt).toBe(debt - usdcAdjDebt + divWadUp(usdcAdjDebt, remaining));
});

it("matches haircut keys case-insensitively", () => {
const haircut = parseUnits("0.1", 18);
const exact = { [exactly[0].market]: haircut };
const lowercase = { [exactly[0].market.toLowerCase()]: haircut };

expect(accountLiquidity(exactly, 0, lowercase)).toStrictEqual(accountLiquidity(exactly, 0, exact));
expect(withdrawLimit(exactly, exactly[0].market, WAD, 0, lowercase)).toBe(
withdrawLimit(exactly, exactly[0].market, WAD, 0, exact),
);
expect(borrowLimit(exactly, exactly[0].market, WAD, 0, lowercase)).toBe(
borrowLimit(exactly, exactly[0].market, WAD, 0, exact),
);
});

it("account liquidity rejects invalid haircuts", () => {
expect(() => accountLiquidity(exactly, 0, { [exactly[0].market]: parseUnits("1.01", 18) })).toThrow(
"haircut outside [0, 1]",
);

expect(() => accountLiquidity(exactly, 0, { [exactly[0].market]: -1n })).toThrow("haircut outside [0, 1]");
});

it("normalizes collateral with a full haircut", () => {
expect(normalizeCollateral(0n, WAD, 1n, WAD, WAD)).toBe(0n);
expect(normalizeCollateral(1n, WAD, 1n, WAD, WAD)).toBe(MAX_UINT256);
});

it("withdraw limit forwards haircuts", () => {
expect(
withdrawLimit(exactly, exactly[0].market, WAD, 0, { [exactly[0].market]: parseUnits("0.01", 18) }),
).toBeLessThan(withdrawLimit(exactly, exactly[0].market, WAD, 0));
});

it("borrow limit forwards haircuts", () => {
expect(
borrowLimit(exactly, exactly[0].market, WAD, 0, { [exactly[0].market]: parseUnits("0.01", 18) }),
).toBeLessThan(borrowLimit(exactly, exactly[0].market, WAD, 0));
});
});

describe("with previewer data", async () => {
Expand Down