diff --git a/.changeset/brave-hoops-live.md b/.changeset/brave-hoops-live.md new file mode 100644 index 0000000..27dcbe6 --- /dev/null +++ b/.changeset/brave-hoops-live.md @@ -0,0 +1,5 @@ +--- +"@exactly/lib": patch +--- + +✨ auditor: add optional haircuts per market diff --git a/src/auditor/accountLiquidity.ts b/src/auditor/accountLiquidity.ts index f266fc5..f586ffb 100644 --- a/src/auditor/accountLiquidity.ts +++ b/src/auditor/accountLiquidity.ts @@ -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"; @@ -7,6 +9,7 @@ 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; @@ -14,6 +17,7 @@ export default function accountLiquidity( let adjCollateral = 0n; let adjDebt = 0n; for (const { + market, isCollateral, floatingBorrowAssets, floatingDepositAssets, @@ -24,7 +28,13 @@ 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) { @@ -32,7 +42,8 @@ export default function accountLiquidity( 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; } return { adjCollateral, adjDebt }; @@ -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), + 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>; + export type AccountLiquidityData = readonly { market: string; decimals: number; diff --git a/src/auditor/borrowLimit.ts b/src/auditor/borrowLimit.ts index 014e5b8..f565e83 100644 --- a/src/auditor/borrowLimit.ts +++ b/src/auditor/borrowLimit.ts @@ -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"; @@ -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"); @@ -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), + ); } diff --git a/src/auditor/healthFactor.ts b/src/auditor/healthFactor.ts index c37e19d..f963444 100644 --- a/src/auditor/healthFactor.ts +++ b/src/auditor/healthFactor.ts @@ -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; } diff --git a/src/auditor/withdrawLimit.ts b/src/auditor/withdrawLimit.ts index 8078a5d..f82dfef 100644 --- a/src/auditor/withdrawLimit.ts +++ b/src/auditor/withdrawLimit.ts @@ -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"; @@ -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"); @@ -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); } diff --git a/test/auditor.test.ts b/test/auditor.test.ts index 1f6bd86..3922a49 100644 --- a/test/auditor.test.ts +++ b/test/auditor.test.ts @@ -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 () => {