diff --git a/src/common-utils/utils/apy/get-single-quota-borrow-rate.ts b/src/common-utils/utils/apy/get-single-quota-borrow-rate.ts index 533366caa..71c2a4448 100644 --- a/src/common-utils/utils/apy/get-single-quota-borrow-rate.ts +++ b/src/common-utils/utils/apy/get-single-quota-borrow-rate.ts @@ -10,6 +10,8 @@ export interface GetSingleQuotaBorrowRateRate extends CalcQuotaBorrowRateProps { /** * Under the hood sums up rates for all given quotas and then multiplies them by 1+feeInterest, * but it is expected that the ONLY quota will be passed + * + * @deprecated Use `calcBorrowRate` from `sdk/positions` instead. */ export function getSingleQuotaBorrowRate( props: GetSingleQuotaBorrowRateRate, diff --git a/src/common-utils/utils/creditAccount/calc-health-factor.ts b/src/common-utils/utils/creditAccount/calc-health-factor.ts index 395cad348..095bd478d 100644 --- a/src/common-utils/utils/creditAccount/calc-health-factor.ts +++ b/src/common-utils/utils/creditAccount/calc-health-factor.ts @@ -1,11 +1,6 @@ import type { Address } from "viem"; -import { - type Asset, - PERCENTAGE_FACTOR, - PRICE_DECIMALS, -} from "../../../sdk/index.js"; -import { BigIntMath } from "../../../sdk/utils/bigint-math.js"; -import { PriceUtils } from "../price-math.js"; +import type { Asset } from "../../../sdk/index.js"; +import { calcHealthFactor as calcHealthFactorFromSnapshot } from "../../../sdk/positions/calcHealthFactor.js"; import type { QuotaInfoIsActiveSlice, TokenDataSlice } from "./types.js"; export interface CalcHealthFactorProps { @@ -20,8 +15,6 @@ export interface CalcHealthFactorProps { tokensList: Record; } -const MAX_UINT16 = 65535; - /** * Computes account health factor in percentage-factor units. * @@ -32,6 +25,9 @@ const MAX_UINT16 = 65535; * @param props Credit account balances, quotas, prices, thresholds, and debt context. * @returns Health factor as a number in `PERCENTAGE_FACTOR` scale, * or `65535` when debt is zero. + * + * @deprecated Use `calcHealthFactor` from `sdk/positions` instead; this + * wrapper only maps the legacy props onto an `AccountSnapshot`. */ export function calcHealthFactor({ assets, @@ -45,52 +41,35 @@ export function calcHealthFactor({ prices, tokensList, }: CalcHealthFactorProps): number { - if (debt === 0n) return MAX_UINT16; - - const underlyingDecimals = tokensList[underlyingToken]?.decimals || 18; - const underlyingPrice = prices[underlyingToken] || 0n; - - const assetMoney = assets.reduce( - (acc, { token: tokenAddress, balance: amount }) => { - const tokenDecimals = tokensList[tokenAddress]?.decimals || 18; - - const lt = liquidationThresholds[tokenAddress] || 0n; - const price = prices[tokenAddress] || 0n; - - const tokenMoney = PriceUtils.calcTotalPrice( - price, - amount, - tokenDecimals, - ); - const tokenLtMoney = (tokenMoney * lt) / PERCENTAGE_FACTOR; - - const { isActive = false } = quotasInfo?.[tokenAddress] || {}; - const quota = quotas[tokenAddress]; - const quotaBalance = isActive ? quota?.balance || 0n : 0n; - const quotaMoney = PriceUtils.calcTotalPrice( - underlyingPrice, - quotaBalance, - underlyingDecimals, - ); - - // if quota is undefined, then it is not a quoted token - const money = quota - ? BigIntMath.min(quotaMoney, tokenLtMoney) - : tokenLtMoney; - - return acc + money; + const decimals: Record = {}; + for (const [token, meta] of Object.entries(tokensList)) { + decimals[token as Address] = meta.decimals; + } + + const lts: Record = {}; + for (const [token, lt] of Object.entries(liquidationThresholds)) { + lts[token as Address] = Number(lt); + } + + const activeQuotas: Record = {}; + for (const [token, info] of Object.entries(quotasInfo)) { + if (info?.isActive) { + activeQuotas[token as Address] = true; + } + } + + return calcHealthFactorFromSnapshot({ + snapshot: { + creditManager: underlyingToken, + assets, + quotas: Object.values(quotas), + totalDebt: debt, + totalValue: 0n, }, - 0n, - ); - - const borrowedMoney = PriceUtils.calcTotalPrice( - underlyingPrice || PRICE_DECIMALS, - debt, - underlyingDecimals, - ); - - const hfInPercent = - borrowedMoney > 0n ? (assetMoney * PERCENTAGE_FACTOR) / borrowedMoney : 0n; - - return Number(hfInPercent); + underlying: underlyingToken, + decimals, + prices, + liquidationThresholds: lts, + activeQuotas, + }); } diff --git a/src/common-utils/utils/creditAccount/calc-quota-borrow-rate.ts b/src/common-utils/utils/creditAccount/calc-quota-borrow-rate.ts index 63bea12c6..93d1effa0 100644 --- a/src/common-utils/utils/creditAccount/calc-quota-borrow-rate.ts +++ b/src/common-utils/utils/creditAccount/calc-quota-borrow-rate.ts @@ -16,6 +16,8 @@ export interface CalcQuotaBorrowRateProps { * * @param props Quota balances and per-token quota rates. * @returns Sum of `balance * rate` terms in percentage-factor scale. + * + * @deprecated Use `calcBorrowRate` from `sdk/positions` instead. */ export function calcQuotaBorrowRate({ quotas, diff --git a/src/common-utils/utils/creditAccount/get-time-to-liquidation.ts b/src/common-utils/utils/creditAccount/get-time-to-liquidation.ts index c99d2c166..79d59df46 100644 --- a/src/common-utils/utils/creditAccount/get-time-to-liquidation.ts +++ b/src/common-utils/utils/creditAccount/get-time-to-liquidation.ts @@ -1,8 +1,4 @@ -import { - PERCENTAGE_DECIMALS, - PERCENTAGE_FACTOR, - SECONDS_PER_YEAR, -} from "../../../sdk/index.js"; +import { calcTimeToLiquidationMs } from "../../../sdk/positions/calcTimeToLiquidationMs.js"; export interface TimeToLiquidationProps { totalBorrowRate_debt: bigint; @@ -19,18 +15,13 @@ export interface TimeToLiquidationProps { * @param props Current health factor and `totalBorrowRate * debt` term. * @returns Milliseconds to liquidation as `bigint`, or `null` when already at/under * liquidation threshold or when borrow-rate exposure is zero. + * + * @deprecated Use `calcTimeToLiquidationMs` from `sdk/positions` instead; + * this wrapper only forwards to the new implementation. */ export function getTimeToLiquidation({ healthFactor, totalBorrowRate_debt, }: TimeToLiquidationProps) { - if (healthFactor <= PERCENTAGE_FACTOR || totalBorrowRate_debt === 0n) - return null; - - // (HF - 1) / (br_D / year) or (HF - 1) * (year / br_D) - const HF_1 = BigInt(healthFactor) - PERCENTAGE_FACTOR; - const brPerYear = - (BigInt(SECONDS_PER_YEAR) * PERCENTAGE_FACTOR * PERCENTAGE_DECIMALS) / - totalBorrowRate_debt; - return (HF_1 * brPerYear * 1000n) / PERCENTAGE_FACTOR; + return calcTimeToLiquidationMs(healthFactor, totalBorrowRate_debt); } diff --git a/src/common-utils/utils/creditAccount/liquidation-price.ts b/src/common-utils/utils/creditAccount/liquidation-price.ts index 334eaae9c..272949607 100644 --- a/src/common-utils/utils/creditAccount/liquidation-price.ts +++ b/src/common-utils/utils/creditAccount/liquidation-price.ts @@ -1,6 +1,6 @@ import type { Address } from "viem"; import type { Asset } from "../../../sdk/index.js"; -import { PERCENTAGE_FACTOR, PRICE_DECIMALS, WAD } from "../../../sdk/index.js"; +import { calcLiquidationPriceForTarget } from "../../../sdk/positions/calcLiquidationPriceForTarget.js"; import type { TokenDataSlice } from "./types.js"; interface LiquidationPriceProps { @@ -24,6 +24,9 @@ interface LiquidationPriceProps { * @param props Debt context, assets, thresholds, and token metadata. * @returns Target token price in `PRICE_DECIMALS` precision that corresponds * to liquidation boundary; returns `0n` when target balance or LT is non-positive. + * + * @deprecated Use `calcLiquidationPriceForTarget` from `sdk/positions` + * instead; this wrapper only maps the legacy props onto an `AccountSnapshot`. */ export function liquidationPrice({ liquidationThresholds, @@ -34,27 +37,27 @@ export function liquidationPrice({ assets, tokensList, }: LiquidationPriceProps) { - const underlyingDecimals = tokensList[underlyingToken]?.decimals || 18; - const { balance: underlyingBalance = 0n } = assets[underlyingToken] || {}; - - // effectiveDebt = Debt - underlyingBalance*LTunderlying - const ltUnderlying = liquidationThresholds[underlyingToken] || 0n; - const effectiveDebt = - ((debt - (underlyingBalance * ltUnderlying) / PERCENTAGE_FACTOR) * WAD) / - 10n ** BigInt(underlyingDecimals); - - const targetDecimals = tokensList[targetToken]?.decimals || 18; - const { balance: targetBalance = 0n } = assets[targetToken] || {}; - const effectiveTargetBalance = - (targetBalance * WAD) / 10n ** BigInt(targetDecimals); - - const lpLT = liquidationThresholds[targetToken] || 0n; - - if (targetBalance <= 0n || lpLT <= 0n) return 0n; - - // priceTarget = effectiveDebt / (lpLT*targetBalance) - return ( - (effectiveDebt * PRICE_DECIMALS * PERCENTAGE_FACTOR) / - (effectiveTargetBalance * lpLT) - ); + const decimals: Record = {}; + for (const [token, meta] of Object.entries(tokensList)) { + decimals[token as Address] = meta.decimals; + } + + const lts: Record = {}; + for (const [token, lt] of Object.entries(liquidationThresholds)) { + lts[token as Address] = Number(lt); + } + + return calcLiquidationPriceForTarget({ + snapshot: { + creditManager: underlyingToken, + assets: Object.values(assets), + quotas: [], + totalDebt: debt, + totalValue: 0n, + }, + targetToken, + underlying: underlyingToken, + decimals, + liquidationThresholds: lts, + }); } diff --git a/src/model/positions.schema.ts b/src/model/positions.schema.ts index a7bc438a5..d29571730 100644 --- a/src/model/positions.schema.ts +++ b/src/model/positions.schema.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { ZodAddress } from "../sdk/utils/zod.js"; +import { ZodAddress, ZodBigInt, ZodHex } from "../sdk/utils/zod.js"; import { isFilterSet } from "./filters.js"; import { booleanParamSchema, @@ -101,6 +101,16 @@ export const poolPositionSchema = z.object({ pnl: pnlBreakdownSchema.optional(), }); +/** + * {@link BorrowRateBreakdown} + **/ +export const borrowRateBreakdownSchema = z.object({ + total: bpsSchema, + totalOnDebt: bpsSchema, + base: bpsSchema, + quotas: z.record(ZodAddress(), bpsSchema), +}); + /** * {@link StrategyPosition} **/ @@ -117,6 +127,9 @@ export const strategyPositionSchema = z.object({ totalDebt: tokenAmountSchema, totalValue: tokenAmountSchema, healthFactor: bpsSchema, + borrowRate: borrowRateBreakdownSchema.optional(), + timeToLiquidation: ZodBigInt().nullable().optional(), + liquidationPrice: ZodBigInt().nullable().optional(), pnl: pnlBreakdownSchema.optional(), collaterals: z.array(positionCollateralSchema), }); diff --git a/src/model/positions.ts b/src/model/positions.ts index 347a727d6..c96f06d34 100644 --- a/src/model/positions.ts +++ b/src/model/positions.ts @@ -150,6 +150,69 @@ export interface PoolPosition { pnl?: PnlBreakdown; } +/** + * Cost of a position's debt broken down by source. + * + * The base rate is what the pool charges on the debt; each quoted collateral + * adds its own quota rate on top. Rates are reported in two normalizations: + * relative to the position's total value and relative to its debt. + **/ +export interface BorrowRateBreakdown { + /** + * Base rate plus quota rates, relative to the position's total value. + **/ + total: Bps; + /** + * Base rate plus quota rates, relative to the debt. This is the rate the + * debt itself grows at, so it feeds {@link PositionMetrics.timeToLiquidation}. + **/ + totalOnDebt: Bps; + /** + * Annual cost of the borrowed underlying itself: the pool's base rate plus + * the credit manager's interest fee. Same value `borrowApy` reports. + **/ + base: Bps; + /** + * Per-token quota rate contribution, relative to the position's total value. + **/ + quotas: Record; +} + +/** + * Health and cost metrics of a credit account's state, actual or projected. + * + * Previews and operation states carry the whole group; on-chain positions + * report only the fields they lack natively, see {@link StrategyPosition}. + **/ +export interface PositionMetrics { + /** + * Health factor in basis points: below `10000` the account is liquidatable. + * + * @example `12500` for a health factor of 1.25 + **/ + healthFactor: Bps; + /** + * Net rate the whole position earns, collateral yield minus borrow cost. + **/ + overallApy: Bps; + /** + * Cost of the debt, broken down by source. + **/ + borrowRate: BorrowRateBreakdown; + /** + * Estimated milliseconds until the health factor decays to `10000` under + * the current borrow rate, or `null` when the debt carries no rate (or the + * account is already liquidatable). + **/ + timeToLiquidation: bigint | null; + /** + * Price of the single non-underlying collateral at which the account + * becomes liquidatable, in the oracle's 8-decimal fixed point, or `null` + * when the account holds zero or several non-underlying assets. + **/ + liquidationPrice: bigint | null; +} + /** * An open credit account of a wallet. **/ @@ -219,6 +282,28 @@ export interface StrategyPosition { * @example `12500` for a health factor of 1.25 **/ healthFactor: Bps; + /** + * Cost of the debt broken down into the pool's base rate and per-token + * quota rates. + * + * @mode onchain + **/ + borrowRate?: BorrowRateBreakdown; + /** + * Estimated milliseconds until the health factor decays to `10000` under + * the current borrow rate, or `null` when it cannot be estimated. + * + * @mode onchain + **/ + timeToLiquidation?: bigint | null; + /** + * Price of the single non-underlying collateral at which the account + * becomes liquidatable, in the oracle's 8-decimal fixed point, or `null` + * when the account holds zero or several non-underlying assets. + * + * @mode onchain + **/ + liquidationPrice?: bigint | null; /** * What the position has earned so far. * diff --git a/src/preview/preview/CreditAccountState.ts b/src/preview/preview/CreditAccountState.ts index 0447b60ec..8abe3e83a 100644 --- a/src/preview/preview/CreditAccountState.ts +++ b/src/preview/preview/CreditAccountState.ts @@ -1,8 +1,10 @@ import type { Address } from "viem"; import { zeroAddress } from "viem"; import { + type AccountSnapshot, AssetsMap, type CreditAccountData, + DUST_THRESHOLD, MIN_INT96, } from "../../sdk/index.js"; @@ -107,6 +109,20 @@ export class CreditAccountState { }); } + /** + * Immutable snapshot of this projected state for `sdk.positions` metric + * methods: dust-filtered balances, all quotas, and {@link totalDebt}. + **/ + public toSnapshot(totalValue: bigint): AccountSnapshot { + return { + creditManager: this.creditManager, + assets: this.balances.toAssets(DUST_THRESHOLD), + quotas: this.quotas.toAssets(0n), + totalDebt: this.totalDebt, + totalValue, + }; + } + public clone(): CreditAccountState { return new CreditAccountState({ creditAccount: this.creditAccount, diff --git a/src/preview/preview/buildDelayedPreview.test.ts b/src/preview/preview/buildDelayedPreview.test.ts index d3b04626f..1297d5add 100644 --- a/src/preview/preview/buildDelayedPreview.test.ts +++ b/src/preview/preview/buildDelayedPreview.test.ts @@ -1,7 +1,12 @@ import type { Address } from "viem"; import { getAddress } from "viem"; import { describe, expect, it } from "vitest"; -import { AssetsMap } from "../../sdk/index.js"; +import { AssetsMap, type OnchainSDK } from "../../sdk/index.js"; +import { calcBorrowRate } from "../../sdk/positions/calcBorrowRate.js"; +import { calcHealthFactor } from "../../sdk/positions/calcHealthFactor.js"; +import { calcLiquidationPrice } from "../../sdk/positions/calcLiquidationPrice.js"; +import { calcTimeToLiquidationMs } from "../../sdk/positions/calcTimeToLiquidationMs.js"; +import type { AccountSnapshot } from "../../sdk/positions/types.js"; import { buildDelayedPreview, type ConvertFn } from "./buildDelayedPreview.js"; import { CreditAccountState } from "./CreditAccountState.js"; import type { DetectedDelayedOperation } from "./detectDelayedOperation.js"; @@ -17,6 +22,111 @@ const PHANTOM = getAddress("0xF126EaCAcf6B14C8985fC195768A55E886Af4208"); const WETH = getAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); const OWNER = getAddress("0xC32FEB4DBd127a1993478Ad6E5250710f838b908"); +/** + * Market stub for the position metrics: USDC, the underlying and the phantom + * token at $1, WETH at $2000, no quota rates, no borrow rate. The tests only + * care that the metrics are present, not about their values. + */ +const metricsSdk = (() => { + const decimals: Record = { + [UNDERLYING]: 6, + [USDC]: 6, + [PHANTOM]: 6, + [WETH]: 18, + }; + const prices: Record = { + [UNDERLYING]: 10n ** 8n, + [USDC]: 10n ** 8n, + [PHANTOM]: 10n ** 8n, + [WETH]: 2000n * 10n ** 8n, + }; + const lts: Record = { + [UNDERLYING]: 9800, + [USDC]: 9800, + [PHANTOM]: 9200, + [WETH]: 8500, + }; + return { + tokensMeta: { + get: (token: Address) => { + const d = decimals[getAddress(token)]; + return d === undefined ? undefined : { decimals: d }; + }, + }, + marketRegister: { + findByCreditManager: () => ({ + pool: { + underlying: UNDERLYING, + pool: { baseInterestRate: 0n }, + pqk: { quotaRate: () => 0, hasActiveQuota: () => false }, + }, + priceOracle: { + convertToUSD: (token: Address, amount: bigint) => { + const addr = getAddress(token); + const price = prices[addr]; + if (price === undefined) { + throw new Error(`no answer found for token ${token}`); + } + const d = decimals[addr] ?? 18; + return (amount * price) / 10n ** BigInt(d); + }, + safeConvertToUSD: (token: Address, amount: bigint) => { + const addr = getAddress(token); + const price = prices[addr]; + if (price === undefined) { + return null; + } + const d = decimals[addr] ?? 18; + return (amount * price) / 10n ** BigInt(d); + }, + }, + }), + findCreditManager: () => ({ + creditManager: { + feeInterest: 0, + liquidationThresholds: { + get: (token: Address) => lts[getAddress(token)], + }, + }, + }), + }, + positions: (() => { + const healthFactor = (snapshot: AccountSnapshot) => + calcHealthFactor({ + snapshot, + underlying: UNDERLYING, + decimals, + prices, + liquidationThresholds: lts, + activeQuotas: {}, + }); + const borrowRate = (snapshot: AccountSnapshot) => + calcBorrowRate({ + snapshot, + baseInterestRate: 0n, + feeInterest: 0, + quotaRates: {}, + }); + return { + healthFactor, + borrowRate, + timeToLiquidation: (snapshot: AccountSnapshot) => + calcTimeToLiquidationMs( + healthFactor(snapshot), + BigInt(borrowRate(snapshot).totalOnDebt), + ), + liquidationPrice: (snapshot: AccountSnapshot) => + calcLiquidationPrice({ + snapshot, + underlying: UNDERLYING, + decimals, + liquidationThresholds: lts, + }), + }; + })(), + } as unknown as OnchainSDK; +})(); + /** * Oracle stub: USDC and the underlying are 1:1 (dcUSDC is a USDC wrapper), * WETH is 2000 underlying per unit; anything else is unpriceable. @@ -91,6 +201,7 @@ describe("buildDelayedPreview CLOSE_ACCOUNT", () => { detected({ type: "CLOSE_ACCOUNT", to: OWNER }), convert, USDC, + metricsSdk, ); expect(preview).toEqual({ operation: "CloseCreditAccount", @@ -116,6 +227,7 @@ describe("buildDelayedPreview CLOSE_ACCOUNT", () => { detected({ type: "CLOSE_ACCOUNT", to: OWNER }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("CloseCreditAccount"); if (preview.operation === "CloseCreditAccount") { @@ -133,6 +245,7 @@ describe("buildDelayedPreview CLOSE_ACCOUNT", () => { detected({ type: "CLOSE_ACCOUNT", to: OWNER }), convert, USDC, + metricsSdk, ); expect(account.balances.get(PHANTOM)).toBe(22070460800n); expect(account.quotas.get(PHANTOM)).toBe(20861060000n); @@ -156,8 +269,12 @@ describe("buildDelayedPreview DECREASE_LEVERAGE", () => { detected({ type: "DECREASE_LEVERAGE" }), convert, USDC, + metricsSdk, ); - expect(preview).toEqual({ + // toMatchObject: the preview also carries position metrics + // (healthFactor, overallApy, borrowRate, timeToLiquidation, + // liquidationPrice), which this test does not pin down + expect(preview).toMatchObject({ operation: "AdjustCreditAccount", creditManager: CREDIT_MANAGER, creditAccount: CREDIT_ACCOUNT, @@ -190,6 +307,7 @@ describe("buildDelayedPreview DECREASE_LEVERAGE", () => { detected({ type: "DECREASE_LEVERAGE" }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -222,6 +340,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -256,6 +375,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -290,6 +410,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -316,6 +437,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -350,6 +472,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -394,6 +517,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -440,6 +564,7 @@ describe("buildDelayedPreview WITHDRAW_COLLATERAL", () => { }), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { @@ -499,8 +624,9 @@ describe("buildDelayedPreview claim-only", () => { detected({ type: "DEPOSIT" }), convert, USDC, + metricsSdk, ); - expect(preview).toEqual(claimOnlyExpectation); + expect(preview).toMatchObject(claimOnlyExpectation); }); it("applies only the claim step when the intent is undefined (Mellow, legacy txs)", () => { @@ -510,8 +636,10 @@ describe("buildDelayedPreview claim-only", () => { detected(undefined), convert, USDC, + metricsSdk, ); - expect(preview).toEqual(claimOnlyExpectation); + // toMatchObject: position metrics are present but not pinned down here + expect(preview).toMatchObject(claimOnlyExpectation); }); }); @@ -533,6 +661,7 @@ describe("buildDelayedPreview unpriceable tokens", () => { detected(undefined), convert, USDC, + metricsSdk, ); expect(preview.operation).toBe("AdjustCreditAccount"); if (preview.operation === "AdjustCreditAccount") { diff --git a/src/preview/preview/buildDelayedPreview.ts b/src/preview/preview/buildDelayedPreview.ts index bc66bef6a..3e23126ca 100644 --- a/src/preview/preview/buildDelayedPreview.ts +++ b/src/preview/preview/buildDelayedPreview.ts @@ -5,6 +5,7 @@ import { AssetsMap, type DelayedWithdrawCollateralIntent, DUST_THRESHOLD, + type OnchainSDK, } from "../../sdk/index.js"; import type { CreditAccountState } from "./CreditAccountState.js"; import type { DetectedDelayedOperation } from "./detectDelayedOperation.js"; @@ -40,6 +41,8 @@ export type ConvertFn = (token: Address, to: Address, amount: bigint) => bigint; * @param receivedToken - Token the `CLOSE_ACCOUNT` resume withdraws to the * user: the unwrapped underlying (vault asset) for RWA markets, the * underlying itself otherwise. + * @param sdk - Market data source for the position metrics of the resulting + * state; read synchronously, no network access. */ export function buildDelayedPreview( afterInstant: CreditAccountState, @@ -47,6 +50,7 @@ export function buildDelayedPreview( detected: DetectedDelayedOperation, convert: ConvertFn, receivedToken: Address, + sdk: OnchainSDK, ): InstantOperationPreview { const { request, intent } = detected; @@ -80,7 +84,7 @@ export function buildDelayedPreview( break; } - return buildAdjustPreview(post, before, collateralWithdrawn, converter); + return buildAdjustPreview(post, before, collateralWithdrawn, converter, sdk); } /** @@ -283,12 +287,16 @@ function buildAdjustPreview( before: CreditAccountState, collateralWithdrawn: AssetsMap, converter: SafeConverter, + sdk: OnchainSDK, ): AdjustCreditAccountPreview { const totalValue = totalValueInUnderlying( post, converter.convert, DUST_THRESHOLD, ); + const assets = post.balances.toAssets(DUST_THRESHOLD); + const quotas = post.quotas.toAssets(0n); + const snap = post.toSnapshot(totalValue); return { operation: "AdjustCreditAccount", creditManager: post.creditManager, @@ -301,12 +309,19 @@ function buildAdjustPreview( // relative to the pre-transaction state: where the account will end up // compared to now, once the withdrawal is claimed and the intent resumed debtChange: post.debt - before.debt, - quotas: post.quotas.toAssets(0n), + quotas, quotasChange: post.quotas.difference(before.quotas).toAssets(), - assets: post.balances.toAssets(DUST_THRESHOLD), + assets, assetsChange: post.balances .difference(before.balances) .toAssets(DUST_THRESHOLD), error: converter.error, + healthFactor: sdk.positions.healthFactor(snap), + // TODO: overall APY needs the collateral yield (lpAPY), which market + // state alone does not carry — wire it up together with the ApyPlugin + overallApy: 0, + borrowRate: sdk.positions.borrowRate(snap), + timeToLiquidation: sdk.positions.timeToLiquidation(snap), + liquidationPrice: sdk.positions.liquidationPrice(snap), }; } diff --git a/src/preview/preview/previewAdjustCreditAccount.ts b/src/preview/preview/previewAdjustCreditAccount.ts index 09f03cf29..0619a5329 100644 --- a/src/preview/preview/previewAdjustCreditAccount.ts +++ b/src/preview/preview/previewAdjustCreditAccount.ts @@ -55,6 +55,7 @@ export async function previewAdjustCreditAccount

( // On a malformed multicall the replayed balances are best-effort and may // be unreliable. const assets = account.balances.toAssets(DUST_THRESHOLD); + const quotas = account.quotas.toAssets(0n); // The replayed state is seeded with all initial tokens and entries are // never deleted, so its keys are the union of tokens present before or @@ -80,6 +81,7 @@ export async function previewAdjustCreditAccount

( return acc; } }, 0n); + const snap = account.toSnapshot(totalValue); return { operation: "AdjustCreditAccount", @@ -90,10 +92,19 @@ export async function previewAdjustCreditAccount

( totalValue, debt: account.debt, debtChange: account.debt - before.debt, - quotas: account.quotas.toAssets(0n), + quotas, quotasChange: account.quotas.difference(before.quotas).toAssets(), assets, assetsChange, error, + // Best-effort like the rest of the preview: tokens the oracle cannot + // price (ERROR_UNPRICEABLE_TOKEN) contribute nothing to the metrics. + healthFactor: sdk.positions.healthFactor(snap), + // TODO: overall APY needs the collateral yield (lpAPY), which market + // state alone does not carry — wire it up together with the ApyPlugin + overallApy: 0, + borrowRate: sdk.positions.borrowRate(snap), + timeToLiquidation: sdk.positions.timeToLiquidation(snap), + liquidationPrice: sdk.positions.liquidationPrice(snap), }; } diff --git a/src/preview/preview/previewOpenCreditAccount.ts b/src/preview/preview/previewOpenCreditAccount.ts index 0e51d3166..4c423f1d2 100644 --- a/src/preview/preview/previewOpenCreditAccount.ts +++ b/src/preview/preview/previewOpenCreditAccount.ts @@ -2,6 +2,7 @@ import { type AddressMap, AP_WETH_TOKEN, type Asset, + DUST_THRESHOLD, NO_VERSION, type PluginsMap, } from "../../sdk/index.js"; @@ -64,7 +65,11 @@ export async function previewOpenCreditAccount

( // filter out dust, including the 1-wei leftovers of drained inputs and // intermediate tokens. On a malformed multicall the replayed balances are // best-effort and may be unreliable. - const assets = account.balances.toAssets(1n); + const assets = account.balances.toAssets(DUST_THRESHOLD); + // On opening, initial quotas are zero, so the folded quotas are the + // applied changes. + const quotas = account.quotas.toAssets(0n); + const snap = account.toSnapshot(collateralValue + account.totalDebt); return { operation: operation.operation, @@ -73,11 +78,18 @@ export async function previewOpenCreditAccount

( collateral, collateralValue, debt: account.debt, - // On opening, initial quotas are zero, so the folded quotas are the - // applied changes. - quotas: account.quotas.toAssets(0n), + quotas, assets, error, + // Best-effort like the rest of the preview: tokens the oracle cannot + // price (ERROR_UNPRICEABLE_TOKEN) contribute nothing to the metrics. + healthFactor: sdk.positions.healthFactor(snap), + // TODO: overall APY needs the collateral yield (lpAPY), which market + // state alone does not carry — wire it up together with the ApyPlugin + overallApy: 0, + borrowRate: sdk.positions.borrowRate(snap), + timeToLiquidation: sdk.positions.timeToLiquidation(snap), + liquidationPrice: sdk.positions.liquidationPrice(snap), }; } diff --git a/src/preview/preview/previewOperation.ts b/src/preview/preview/previewOperation.ts index 8e7cf5dcf..adf692157 100644 --- a/src/preview/preview/previewOperation.ts +++ b/src/preview/preview/previewOperation.ts @@ -158,6 +158,7 @@ async function previewMulticallOperation

( delayed, convert, receivedToken, + sdk, ), }; } diff --git a/src/preview/preview/types.ts b/src/preview/preview/types.ts index 319827c8f..97ccb3c9f 100644 --- a/src/preview/preview/types.ts +++ b/src/preview/preview/types.ts @@ -1,4 +1,5 @@ import type { Address } from "viem"; +import type { PositionMetrics } from "../../model/index.js"; import type { Asset, DelayedIntent } from "../../sdk/index.js"; import type { PoolOperationType } from "../parse/index.js"; @@ -96,7 +97,7 @@ export interface PoolOperationPreview { error?: OperationPreviewError; } -export interface OpenCreditAccountPreview { +export interface OpenCreditAccountPreview extends PositionMetrics { operation: "OpenCreditAccount" | "RWAOpenCreditAccount"; /** * Credit manager the account is opened in @@ -140,7 +141,7 @@ export interface OpenCreditAccountPreview { error?: OperationPreviewError; } -export interface AdjustCreditAccountPreview { +export interface AdjustCreditAccountPreview extends PositionMetrics { operation: "AdjustCreditAccount"; /** * Credit manager the account is opened in diff --git a/src/sdk/accounts/CreditAccountsServiceV310.ts b/src/sdk/accounts/CreditAccountsServiceV310.ts index 7c724307b..2c1696404 100644 --- a/src/sdk/accounts/CreditAccountsServiceV310.ts +++ b/src/sdk/accounts/CreditAccountsServiceV310.ts @@ -3,7 +3,6 @@ import { encodeFunctionData } from "viem"; import { rewardsCompressorAbi } from "../../abi/compressors/rewardsCompressor.js"; import { iBaseRewardPoolAbi } from "../../abi/iBaseRewardPool.js"; import { ierc4626AdapterAbi } from "../../abi/ierc4626Adapter.js"; -import type { StrategyPosition } from "../../model/index.js"; import type { Asset, CreditAccountData, @@ -34,7 +33,6 @@ import { AccountBotsService } from "./bots/index.js"; import { CreditAccountCompressor, type GetCreditAccountsOptions, - type ListStrategyPositionsProps, } from "./credit-account-compressor/index.js"; import { extractPriceUpdates, @@ -124,15 +122,6 @@ export class CreditAccountsServiceV310 ); } - /** - * {@inheritDoc ICreditAccountsService.listPositions} - **/ - public async listPositions( - props: ListStrategyPositionsProps, - ): Promise { - return this.#compressor.listPositions(props); - } - /** * {@inheritDoc ICreditAccountsService.getRewards} **/ diff --git a/src/sdk/accounts/credit-account-compressor/CreditAccountCompressor.ts b/src/sdk/accounts/credit-account-compressor/CreditAccountCompressor.ts index e16a05ff2..fb671805f 100644 --- a/src/sdk/accounts/credit-account-compressor/CreditAccountCompressor.ts +++ b/src/sdk/accounts/credit-account-compressor/CreditAccountCompressor.ts @@ -1,47 +1,28 @@ import type { Address } from "viem"; import { iRWAFactoryAbi } from "../../../abi/rwa/iRWAFactory.js"; -import type { - DelayedReceivedAsset, - StrategyPosition, -} from "../../../model/index.js"; import type { CreditAccountData } from "../../base/index.js"; import { SDKConstruct } from "../../base/index.js"; import { ADDRESS_0X0, AP_CREDIT_ACCOUNT_COMPRESSOR, - DUST_THRESHOLD, MAX_UINT256, VERSION_RANGE_310, } from "../../constants/index.js"; -import { dominantCollateral } from "../../market/index.js"; -import { - calcBorrowApy, - calcPositionLeverage, - healthFactorBps, - usdToNumber, -} from "../../market/math.js"; -import { AddressMap, AddressSet, hexEq } from "../../utils/index.js"; +import { AddressSet, hexEq } from "../../utils/index.js"; import { simulateWithPriceUpdates } from "../../utils/viem/index.js"; -import type { - ClaimableWithdrawal, - PendingWithdrawal, - WithdrawalOutput, -} from "../withdrawal-compressor/index.js"; import { CreditAccountCompressorV310Contract } from "./CreditAccountCompressorV310Contract.js"; import type { CreditAccountFilter, CreditManagerFilter, GetCreditAccountsOptions, - ListStrategyPositionsProps, } from "./types.js"; /** * Reads credit accounts of the current chain. * * Stitches the credit account compressor together with the RWA factories (for - * accounts owned via an investor EOA) and with the withdrawal compressor (for - * assets that are on their way out of an account), and describes the result - * either as raw account data or as {@link StrategyPosition}s. + * accounts owned via an investor EOA), and describes the result as raw + * account data. * * TODO: create and deploy new compressor contract onchain to avoid all this stitching **/ @@ -276,174 +257,6 @@ export class CreditAccountCompressor extends SDKConstruct { return filtered.sort((a, b) => Number(a.healthFactor - b.healthFactor)); } - /** - * Describes all credit accounts of a wallet as strategy positions. - * - * @param props - {@link ListStrategyPositionsProps} - **/ - public async listPositions( - props: ListStrategyPositionsProps, - ): Promise { - const { owner, includeZeroDebt, blockNumber } = props; - const [accounts] = await Promise.all([ - this.getBorrowerCreditAccounts(owner, { includeZeroDebt }, blockNumber), - // phantom token lookups below are sync, so the cache has to be warm - this.sdk.withdrawalCompressor?.loadWithdrawableAssets( - undefined, - blockNumber, - ), - ]); - - const describable = accounts.filter(ca => { - // collateral computation reverted (e.g. dead price feed) — none of the - // account's amounts can be computed, so it is left out of the list - if (!ca.success) { - this.logger?.warn( - `cannot describe position of ${this.labelAddress(ca.creditAccount)}: collateral computation failed`, - ); - } - return ca.success; - }); - - const withdrawals = await Promise.all( - describable.map(ca => this.#accountWithdrawals(ca, blockNumber)), - ); - - return describable.map((ca, i) => - this.#toStrategyPosition(ca, withdrawals[i] ?? new AddressMap()), - ); - } - - /** - * Builds one strategy position from an account snapshot. - * - * @param withdrawals - Delayed withdrawals of the account, keyed by the - * phantom token that represents them on it. - **/ - #toStrategyPosition( - ca: CreditAccountData, - withdrawals: AddressMap, - ): StrategyPosition { - const suite = this.sdk.marketRegister.findCreditManager(ca.creditManager); - const { market } = suite; - const { priceOracle } = market; - const { pool } = market.pool; - - // for RWA markets, amounts are denominated in the unwrapped asset - // (e.g. USDC instead of dcUSDC); the wrapped underlying converts 1:1 - const token = this.sdk.tokensMeta.mustGetToken(market.unwrappedUnderlying); - const totalDebtValue = ca.debt + ca.accruedInterest + ca.accruedFees; - const collateral = dominantCollateral(ca, market); - - return { - kind: "strategy", - chainId: this.sdk.chainId, - creditManager: ca.creditManager, - creditAccount: ca.creditAccount, - name: collateral ? suite.strategyName(collateral) : token.symbol, - // the read model asks for the collateral the position was opened into, - // which needs its history; the chain can only tell what it holds now - targetCollateral: collateral - ? this.sdk.tokensMeta.mustGetToken(collateral) - : null, - leverage: calcPositionLeverage(ca.totalValue, totalDebtValue), - borrowApy: calcBorrowApy( - pool.baseInterestRate, - suite.creditManager.feeInterest, - ), - // the compressor prices the whole account in one pass, so the USD values - // of the two totals come from it rather than from a second price lookup - totalDebt: { - token, - value: totalDebtValue, - valueUsd: usdToNumber(ca.totalDebtUSD), - }, - totalValue: { - token, - value: ca.totalValue, - valueUsd: usdToNumber(ca.totalValueUSD), - }, - healthFactor: healthFactorBps(ca.healthFactor), - collaterals: ca.tokens.flatMap(t => { - if ( - (t.mask & ca.enabledTokensMask) === 0n || - t.balance <= DUST_THRESHOLD - ) { - return []; - } - return [ - { - // phantom tokens are reported as themselves, the asset they - // redeem into shows up in `withdrawals` - collateral: priceOracle.toTokenAmount(t.token, t.balance), - quota: priceOracle.toTokenAmount(market.underlying, t.quota), - withdrawals: withdrawals.get(t.token) ?? [], - }, - ]; - }), - }; - } - - /** - * Delayed withdrawals of one account, keyed by the phantom token that - * represents them on it, so that each collateral row can pick up its own. - **/ - async #accountWithdrawals( - ca: CreditAccountData, - blockNumber?: bigint, - ): Promise> { - const compressor = this.sdk.withdrawalCompressor; - const byPhantomToken = new AddressMap( - undefined, - "accountWithdrawals", - ); - // an account with no phantom token balance has nothing on its way out, and - // asking the compressor about it would be one RPC call per such account - const holdsPhantomToken = ca.tokens.some( - t => - t.balance > DUST_THRESHOLD && - compressor?.getWithdrawalSourceToken(t.token) !== undefined, - ); - if (!compressor || !holdsPhantomToken) { - return byPhantomToken; - } - const { priceOracle } = this.sdk.marketRegister.findByCreditManager( - ca.creditManager, - ); - const { claimable, pending } = await compressor.getCurrentWithdrawals( - ca.creditAccount, - blockNumber, - ); - - const add = ( - w: ClaimableWithdrawal | PendingWithdrawal, - outputs: readonly WithdrawalOutput[], - claimableAt?: bigint, - ): void => { - const assets = outputs.map( - (o): DelayedReceivedAsset => ({ - isDelayed: true, - ...priceOracle.toTokenAmount(o.token, o.amount), - redeemer: w.redeemer, - claimableAt: - claimableAt === undefined ? undefined : Number(claimableAt), - }), - ); - byPhantomToken.upsert(w.withdrawalPhantomToken, [ - ...(byPhantomToken.get(w.withdrawalPhantomToken) ?? []), - ...assets, - ]); - }; - - for (const w of claimable) { - add(w, w.outputs); - } - for (const w of pending) { - add(w, w.expectedOutputs, w.claimableAt); - } - return byPhantomToken; - } - /** * Credit account compressor contract of the current chain. * diff --git a/src/sdk/accounts/credit-account-compressor/types.ts b/src/sdk/accounts/credit-account-compressor/types.ts index 0e27f426a..4155aa260 100644 --- a/src/sdk/accounts/credit-account-compressor/types.ts +++ b/src/sdk/accounts/credit-account-compressor/types.ts @@ -170,22 +170,3 @@ export interface GetCreditAccountsOptions { **/ ignoreReservePrices?: boolean; } - -/** - * Props for {@link CreditAccountCompressor.listPositions}. - **/ -export interface ListStrategyPositionsProps { - /** - * Wallet whose credit accounts to describe. RWA accounts are resolved from - * the investor EOA, see {@link CreditAccountCompressor.getBorrowerCreditAccounts}. - **/ - owner: Address; - /** - * Whether to include accounts that carry no debt. - **/ - includeZeroDebt: boolean; - /** - * Block to read at. Defaults to the latest block. - **/ - blockNumber?: bigint; -} diff --git a/src/sdk/accounts/intents/testing/sdk-mock.ts b/src/sdk/accounts/intents/testing/sdk-mock.ts index 68dd0a53a..e2e148dbb 100644 --- a/src/sdk/accounts/intents/testing/sdk-mock.ts +++ b/src/sdk/accounts/intents/testing/sdk-mock.ts @@ -97,6 +97,12 @@ interface BuildMockSdkArgs { quotas: Record; liquidationThresholds: Record; maxDebt: bigint; + /** Facade `minDebt`; defaults to 0n so debt-range checks stay opt-in. */ + minDebt?: bigint; + /** Pool base rate in ray; feeds `calcBorrowApy` of position metrics. */ + baseInterestRate?: bigint; + /** Credit manager interest fee in Bps; feeds position metrics. */ + feeInterest?: number; creditManager: Address; creditFacade: Address; /** Market underlying token (`market.pool.underlying`). */ @@ -151,13 +157,48 @@ export function buildMockSdk(args: BuildMockSdkArgs): OnchainSDK { })), }; + const quotaOf = (token: Address): MockQuotaEntry | undefined => + args.quotas[token.toLowerCase() as Address] ?? args.quotas[token]; + const liquidationThresholds = { entries: () => Object.entries(args.liquidationThresholds), + get: (token: Address) => + args.liquidationThresholds[token.toLowerCase() as Address] ?? + args.liquidationThresholds[token], }; const market = { - priceOracle: { convert }, - pool: { pqk: { quotas }, underlying: args.underlying }, + priceOracle: { + convert, + convertToUSD: (token: Address, amount: bigint) => { + const from = token.toLowerCase() as Address; + const price = args.prices[from] ?? args.prices[token]; + if (price === undefined) { + throw new Error(`mock priceOracle: missing price for ${from}`); + } + return (amount * price) / 10n ** BigInt(decimalsOf(from)); + }, + safeConvertToUSD: (token: Address, amount: bigint) => { + const from = token.toLowerCase() as Address; + const price = args.prices[from] ?? args.prices[token]; + if (price === undefined) { + return null; + } + return (amount * price) / 10n ** BigInt(decimalsOf(from)); + }, + }, + pool: { + pqk: { + quotas, + quotaRate: (token: Address) => Number(quotaOf(token)?.rate ?? 0n), + hasActiveQuota: (token: Address) => { + const q = quotaOf(token); + return !!q?.isActive && q.limit > 0n; + }, + }, + pool: { baseInterestRate: args.baseInterestRate ?? 0n }, + underlying: args.underlying, + }, }; const creditManagerSuite = { @@ -165,6 +206,7 @@ export function buildMockSdk(args: BuildMockSdkArgs): OnchainSDK { address: args.creditManager, liquidationThresholds, collateralTokens: [], + feeInterest: args.feeInterest ?? 0, }, creditFacade: { address: args.creditFacade, diff --git a/src/sdk/accounts/intents/utils/adjust-state-to-snapshot.ts b/src/sdk/accounts/intents/utils/adjust-state-to-snapshot.ts new file mode 100644 index 000000000..597b51804 --- /dev/null +++ b/src/sdk/accounts/intents/utils/adjust-state-to-snapshot.ts @@ -0,0 +1,21 @@ +import type { Address } from "viem"; +import type { AccountSnapshot } from "../../../positions/index.js"; +import type { AdjustState } from "../types.js"; + +/** + * Maps an intents {@link AdjustState} onto the {@link AccountSnapshot} that + * position-metric functions take. `accountDebt` is treated as total debt + * (principal plus accrued interest and fees). + **/ +export function adjustStateToSnapshot( + creditManager: Address, + state: AdjustState, +): AccountSnapshot { + return { + creditManager, + assets: state.assets, + quotas: Object.values(state.quotas), + totalDebt: state.accountDebt, + totalValue: state.totalValue, + }; +} diff --git a/src/sdk/accounts/intents/utils/index.ts b/src/sdk/accounts/intents/utils/index.ts index 4a47149a5..c951da595 100644 --- a/src/sdk/accounts/intents/utils/index.ts +++ b/src/sdk/accounts/intents/utils/index.ts @@ -1,3 +1,4 @@ +export * from "./adjust-state-to-snapshot.js"; export * from "./assemble-operation-calls.js"; export * from "./borrowed-amount-plus-interest-and-fees.js"; export * from "./common.js"; diff --git a/src/sdk/accounts/liquidations/LiquidationsService.ts b/src/sdk/accounts/liquidations/LiquidationsService.ts index fe204bd8d..d86f1c9d1 100644 --- a/src/sdk/accounts/liquidations/LiquidationsService.ts +++ b/src/sdk/accounts/liquidations/LiquidationsService.ts @@ -64,11 +64,6 @@ export class LiquidationsService extends SDKConstruct { public async getLiquidatableAccounts( props?: GetLiquidatableAccountsProps, ): Promise { - await this.sdk.withdrawalCompressor?.loadWithdrawableAssets( - undefined, - props?.blockNumber, - ); - const unhealthy = await this.sdk.accounts.getCreditAccounts( { maxHealthFactor: WAD - 1n, @@ -110,10 +105,6 @@ export class LiquidationsService extends SDKConstruct { const suite = this.sdk.marketRegister.findCreditManager(ca.creditManager); const { priceOracle } = suite.market; - await this.sdk.withdrawalCompressor?.loadWithdrawableAssets( - undefined, - blockNumber, - ); const account = this.#buildAccount(ca, suite); const data = await this.#getLiquidationData( ca, @@ -182,7 +173,6 @@ export class LiquidationsService extends SDKConstruct { if (!compressor) { return []; } - await compressor.loadWithdrawableAssets(undefined, props.blockNumber); // the same phantom token can be configured in several credit managers; // duplicates would double-count redeemers in the compressor's loop const phantomTokens = new AddressSet( @@ -448,7 +438,7 @@ export class LiquidationsService extends SDKConstruct { } // requires the compressor's withdrawable assets cache to be loaded - // (see `loadWithdrawableAssets`) so that phantom token lookups are sync + // (by attach/hydrate) so that phantom token lookups are sync #buildAccount( ca: CreditAccountData, suite = this.sdk.marketRegister.findCreditManager(ca.creditManager), diff --git a/src/sdk/accounts/types.ts b/src/sdk/accounts/types.ts index 5005f1af4..51ba2ab13 100644 --- a/src/sdk/accounts/types.ts +++ b/src/sdk/accounts/types.ts @@ -1,5 +1,4 @@ import type { Address, Hex } from "viem"; -import type { StrategyPosition } from "../../model/index.js"; import type { Asset, Construct, @@ -22,10 +21,7 @@ import type { OnchainSDK } from "../OnchainSDK.js"; import type { RouterCASlice, RouterCloseResult } from "../router/index.js"; import type { MultiCall, RawTx } from "../types/index.js"; import type { AccountBotsService } from "./bots/index.js"; -import type { - GetCreditAccountsOptions, - ListStrategyPositionsProps, -} from "./credit-account-compressor/index.js"; +import type { GetCreditAccountsOptions } from "./credit-account-compressor/index.js"; import type { ClaimableWithdrawal, DelayedIntent, @@ -440,16 +436,6 @@ export interface ICreditAccountsService extends Construct { blockNumber?: bigint, ): Promise>>; - /** - * Describes the open credit accounts of a wallet as the shared read model's - * strategy positions. - * - * @param props - {@link ListStrategyPositionsProps} - * @returns One row per open account. Accounts whose collateral computation - * failed are excluded, because none of their amounts can be computed. - */ - listPositions(props: ListStrategyPositionsProps): Promise; - /** * Method to get all claimable rewards for credit account (ex. stkUSDS SKY rewards). * Associates rewards by adapter + stakedPhantomToken. diff --git a/src/sdk/market/oracle/PriceOracleBaseContract.ts b/src/sdk/market/oracle/PriceOracleBaseContract.ts index 6fde2cf75..f3540ad5d 100644 --- a/src/sdk/market/oracle/PriceOracleBaseContract.ts +++ b/src/sdk/market/oracle/PriceOracleBaseContract.ts @@ -249,17 +249,25 @@ export abstract class PriceOracleBaseContract< } /** - * {@inheritDoc IPriceOracleContract.safeUsdValue} + * {@inheritDoc IPriceOracleContract.safeConvertToUSD} **/ - public safeUsdValue(token: Address, amount: bigint): number | null { + public safeConvertToUSD(token: Address, amount: bigint): bigint | null { try { - return usdToNumber(this.convertToUSD(token, amount)); + return this.convertToUSD(token, amount); } catch (e) { this.logger?.debug(`cannot price ${this.labelAddress(token)}: ${e}`); return null; } } + /** + * {@inheritDoc IPriceOracleContract.safeUsdValue} + **/ + public safeUsdValue(token: Address, amount: bigint): number | null { + const usd = this.safeConvertToUSD(token, amount); + return usd === null ? null : usdToNumber(usd); + } + // bound fields, not methods: the read-model mappers are meant to be handed // to code that maps a list of amounts and does not know the oracle /** diff --git a/src/sdk/market/oracle/types.ts b/src/sdk/market/oracle/types.ts index 04f4aaf46..e804ef4c9 100644 --- a/src/sdk/market/oracle/types.ts +++ b/src/sdk/market/oracle/types.ts @@ -163,6 +163,14 @@ export interface IPriceOracleContract extends IBaseContract { * @param reserve - Use reserve feeds instead of main. **/ convertToUSD: (from: Address, amount: bigint, reserve?: boolean) => bigint; + /** + * Like {@link convertToUSD}, but returns `null` instead of throwing when + * the token cannot be priced (missing or unsuccessful feed). + * + * @param token - Token address. + * @param amount - Amount in token decimals. + **/ + safeConvertToUSD: (token: Address, amount: bigint) => bigint | null; /** * Converts a USD amount to a token amount using latest known prices. * @param to - Token address. diff --git a/src/sdk/positions/PositionsService.ts b/src/sdk/positions/PositionsService.ts index f37eb1e2c..fb2762e41 100644 --- a/src/sdk/positions/PositionsService.ts +++ b/src/sdk/positions/PositionsService.ts @@ -1,7 +1,55 @@ -import type { Position, PositionKind } from "../../model/index.js"; +import type { Address } from "viem"; +import type { + BorrowRateBreakdown, + Bps, + DelayedReceivedAsset, + Position, + PositionKind, + StrategyPosition, +} from "../../model/index.js"; import { isFilterSet, matchesPositionFilter } from "../../model/index.js"; +import type { + ClaimableWithdrawal, + PendingWithdrawal, + WithdrawalOutput, +} from "../accounts/withdrawal-compressor/index.js"; +import type { CreditAccountData } from "../base/index.js"; import { SDKConstruct } from "../base/index.js"; -import type { ListPositionsProps } from "./types.js"; +import { DUST_THRESHOLD } from "../constants/index.js"; +import { dominantCollateral } from "../market/index.js"; +import { + calcBorrowApy, + calcPositionLeverage, + healthFactorBps, + usdToNumber, +} from "../market/math.js"; +import { AddressMap } from "../utils/index.js"; +import { calcBorrowRate } from "./calcBorrowRate.js"; +import { calcHealthFactor } from "./calcHealthFactor.js"; +import { calcLiquidationPrice } from "./calcLiquidationPrice.js"; +import { calcTimeToLiquidationMs } from "./calcTimeToLiquidationMs.js"; +import { + type AccountSnapshot, + accountSnapshotFromCreditAccountData, + type ListPositionsProps, + type ListStrategyPositionsProps, +} from "./types.js"; + +/** + * Market-side inputs collected once from the SDK for a snapshot's credit + * manager: prices, decimals and thresholds for the snapshot's tokens plus + * the market underlying (even when the account holds none of it). + **/ +interface PositionMetricMarketData { + underlying: Address; + decimals: Record; + prices: Record; + liquidationThresholds: Record; + activeQuotas: Record; + quotaRates: Record; + baseInterestRate: bigint; + feeInterest: number; +} /** * The `positions` read model of one chain: everything a wallet holds in the @@ -31,7 +79,7 @@ export class PositionsService extends SDKConstruct { ? this.sdk.pools.listPositions({ wallet, blockNumber }) : Promise.resolve([]), wanted("strategy") - ? this.sdk.accounts.listPositions({ + ? this.listStrategyPositions({ owner: wallet, // a filter that asks for accounts with debt narrows the account // query itself; anything else needs them all @@ -51,4 +99,315 @@ export class PositionsService extends SDKConstruct { matchesPositionFilter(row, filter), ); } + + /** + * Describes all credit accounts of a wallet as strategy positions. + * + * @param props - {@link ListStrategyPositionsProps} + **/ + public async listStrategyPositions( + props: ListStrategyPositionsProps, + ): Promise { + const { owner, includeZeroDebt, blockNumber } = props; + // phantom token lookups below are sync; the cache is populated by attach/hydrate + const accounts = await this.sdk.accounts.getBorrowerCreditAccounts( + owner, + { includeZeroDebt }, + blockNumber, + ); + + const describable = accounts.filter(ca => { + // collateral computation reverted (e.g. dead price feed) — none of the + // account's amounts can be computed, so it is left out of the list + if (!ca.success) { + this.logger?.warn( + `cannot describe position of ${this.labelAddress(ca.creditAccount)}: collateral computation failed`, + ); + } + return ca.success; + }); + + const withdrawals = await Promise.all( + describable.map(ca => this.#accountWithdrawals(ca, blockNumber)), + ); + + return describable.map((ca, i) => + this.#toStrategyPosition(ca, withdrawals[i] ?? new AddressMap()), + ); + } + + /** + * Health factor of an account state, in basis points (`10000` = 1.0). + **/ + public healthFactor(snapshot: AccountSnapshot): Bps { + const data = this.#marketData(snapshot); + return calcHealthFactor({ + snapshot, + underlying: data.underlying, + decimals: data.decimals, + prices: data.prices, + liquidationThresholds: data.liquidationThresholds, + activeQuotas: data.activeQuotas, + }); + } + + /** + * Cost of an account state's debt, broken down into the pool's base rate + * and per-token quota rates. + **/ + public borrowRate(snapshot: AccountSnapshot): BorrowRateBreakdown { + const data = this.#marketData(snapshot); + return calcBorrowRate({ + snapshot, + baseInterestRate: data.baseInterestRate, + feeInterest: data.feeInterest, + quotaRates: data.quotaRates, + }); + } + + /** + * Estimated milliseconds until the account's health factor decays to + * `10000` under its current borrow rate, or `null` when the debt carries + * no rate (or the account is already liquidatable). + **/ + public timeToLiquidation(snapshot: AccountSnapshot): bigint | null { + const data = this.#marketData(snapshot); + return calcTimeToLiquidationMs( + calcHealthFactor({ + snapshot, + underlying: data.underlying, + decimals: data.decimals, + prices: data.prices, + liquidationThresholds: data.liquidationThresholds, + activeQuotas: data.activeQuotas, + }), + BigInt( + calcBorrowRate({ + snapshot, + baseInterestRate: data.baseInterestRate, + feeInterest: data.feeInterest, + quotaRates: data.quotaRates, + }).totalOnDebt, + ), + ); + } + + /** + * Price of the single non-underlying collateral at which the account + * becomes liquidatable, or `null` when the account holds zero or several + * non-underlying assets. + **/ + public liquidationPrice(snapshot: AccountSnapshot): bigint | null { + const data = this.#marketData(snapshot); + return calcLiquidationPrice({ + snapshot, + underlying: data.underlying, + decimals: data.decimals, + liquidationThresholds: data.liquidationThresholds, + }); + } + + /** + * Builds one strategy position from an account snapshot. + * + * @param withdrawals - Delayed withdrawals of the account, keyed by the + * phantom token that represents them on it. + **/ + #toStrategyPosition( + ca: CreditAccountData, + withdrawals: AddressMap, + ): StrategyPosition { + const suite = this.sdk.marketRegister.findCreditManager(ca.creditManager); + const { market } = suite; + const { priceOracle } = market; + const { pool } = market.pool; + + // for RWA markets, amounts are denominated in the unwrapped asset + // (e.g. USDC instead of dcUSDC); the wrapped underlying converts 1:1 + const token = this.sdk.tokensMeta.mustGetToken(market.unwrappedUnderlying); + const totalDebtValue = ca.debt + ca.accruedInterest + ca.accruedFees; + const collateral = dominantCollateral(ca, market); + + // healthFactor / leverage / borrowApy / netApy keep their existing + // sources; only the fields the position does not have natively are filled + const snapshot = accountSnapshotFromCreditAccountData(ca); + const borrowRate = this.borrowRate(snapshot); + const timeToLiquidation = this.timeToLiquidation(snapshot); + const liquidationPrice = this.liquidationPrice(snapshot); + + return { + kind: "strategy", + chainId: this.sdk.chainId, + creditManager: ca.creditManager, + creditAccount: ca.creditAccount, + name: collateral ? suite.strategyName(collateral) : token.symbol, + // the read model asks for the collateral the position was opened into, + // which needs its history; the chain can only tell what it holds now + targetCollateral: collateral + ? this.sdk.tokensMeta.mustGetToken(collateral) + : null, + leverage: calcPositionLeverage(ca.totalValue, totalDebtValue), + borrowApy: calcBorrowApy( + pool.baseInterestRate, + suite.creditManager.feeInterest, + ), + // the compressor prices the whole account in one pass, so the USD values + // of the two totals come from it rather than from a second price lookup + totalDebt: { + token, + value: totalDebtValue, + valueUsd: usdToNumber(ca.totalDebtUSD), + }, + totalValue: { + token, + value: ca.totalValue, + valueUsd: usdToNumber(ca.totalValueUSD), + }, + healthFactor: healthFactorBps(ca.healthFactor), + borrowRate, + timeToLiquidation, + liquidationPrice, + collaterals: ca.tokens.flatMap(t => { + if ( + (t.mask & ca.enabledTokensMask) === 0n || + t.balance <= DUST_THRESHOLD + ) { + return []; + } + return [ + { + // phantom tokens are reported as themselves, the asset they + // redeem into shows up in `withdrawals` + collateral: priceOracle.toTokenAmount(t.token, t.balance), + quota: priceOracle.toTokenAmount(market.underlying, t.quota), + withdrawals: withdrawals.get(t.token) ?? [], + }, + ]; + }), + }; + } + + /** + * Delayed withdrawals of one account, keyed by the phantom token that + * represents them on it, so that each collateral row can pick up its own. + **/ + async #accountWithdrawals( + ca: CreditAccountData, + blockNumber?: bigint, + ): Promise> { + const compressor = this.sdk.withdrawalCompressor; + const byPhantomToken = new AddressMap( + undefined, + "accountWithdrawals", + ); + // an account with no phantom token balance has nothing on its way out, and + // asking the compressor about it would be one RPC call per such account + const holdsPhantomToken = ca.tokens.some( + t => + t.balance > DUST_THRESHOLD && + compressor?.getWithdrawalSourceToken(t.token) !== undefined, + ); + if (!compressor || !holdsPhantomToken) { + return byPhantomToken; + } + const { priceOracle } = this.sdk.marketRegister.findByCreditManager( + ca.creditManager, + ); + const { claimable, pending } = await compressor.getCurrentWithdrawals( + ca.creditAccount, + blockNumber, + ); + + const add = ( + w: ClaimableWithdrawal | PendingWithdrawal, + outputs: readonly WithdrawalOutput[], + claimableAt?: bigint, + ): void => { + const assets = outputs.map( + (o): DelayedReceivedAsset => ({ + isDelayed: true, + ...priceOracle.toTokenAmount(o.token, o.amount), + redeemer: w.redeemer, + claimableAt: + claimableAt === undefined ? undefined : Number(claimableAt), + }), + ); + byPhantomToken.upsert(w.withdrawalPhantomToken, [ + ...(byPhantomToken.get(w.withdrawalPhantomToken) ?? []), + ...assets, + ]); + }; + + for (const w of claimable) { + add(w, w.outputs); + } + for (const w of pending) { + add(w, w.expectedOutputs, w.claimableAt); + } + return byPhantomToken; + } + + /** + * Collects decimals, prices and thresholds for the snapshot's tokens plus + * the market underlying, even when the account holds no underlying balance. + **/ + #marketData(snapshot: AccountSnapshot): PositionMetricMarketData { + const market = this.sdk.marketRegister.findByCreditManager( + snapshot.creditManager, + ); + const cm = this.sdk.marketRegister.findCreditManager( + snapshot.creditManager, + ).creditManager; + const { priceOracle } = market; + const underlying = market.pool.underlying; + const { pqk, pool } = market.pool; + + const tokens: Address[] = [underlying]; + for (const a of snapshot.assets) { + tokens.push(a.token); + } + for (const q of snapshot.quotas) { + tokens.push(q.token); + } + + const decimals: Record = {}; + const prices: Record = {}; + const liquidationThresholds: Record = {}; + const activeQuotas: Record = {}; + const quotaRates: Record = {}; + + for (const token of tokens) { + const meta = this.sdk.tokensMeta.get(token); + if (meta) { + decimals[token] = meta.decimals; + } + + try { + prices[token] = priceOracle.mainPrice(token); + } catch { + // unpriceable: omitted so the calc treats the token as contributing nothing + } + + const lt = cm.liquidationThresholds.get(token); + if (lt !== undefined) { + liquidationThresholds[token] = lt; + } + + if (pqk.hasActiveQuota(token)) { + activeQuotas[token] = true; + quotaRates[token] = pqk.quotaRate(token); + } + } + + return { + underlying, + decimals, + prices, + liquidationThresholds, + activeQuotas, + quotaRates, + baseInterestRate: pool.baseInterestRate, + feeInterest: cm.feeInterest, + }; + } } diff --git a/src/sdk/positions/calcBorrowRate.test.ts b/src/sdk/positions/calcBorrowRate.test.ts new file mode 100644 index 000000000..cd320890d --- /dev/null +++ b/src/sdk/positions/calcBorrowRate.test.ts @@ -0,0 +1,119 @@ +import type { Address } from "viem"; +import { describe, expect, it } from "vitest"; +import { calcBorrowRate } from "./calcBorrowRate.js"; +import type { AccountSnapshot } from "./types.js"; + +const WETH = + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".toLowerCase() as Address; +const DAI = + "0x6B175474E89094C44Da98b954EedeAC495271d0F".toLowerCase() as Address; + +// 2% base rate in ray +const baseInterestRate = 2n * 10n ** 25n; + +function snapshot(partial: Partial): AccountSnapshot { + return { + creditManager: DAI, + assets: [], + quotas: [], + totalDebt: 0n, + totalValue: 0n, + ...partial, + }; +} + +describe("calcBorrowRate", () => { + it("breaks the rate down into base and per-token quotas", () => { + const result = calcBorrowRate({ + snapshot: snapshot({ + totalDebt: 5n, + totalValue: 10n, + quotas: [{ token: WETH, balance: 100n }], + }), + baseInterestRate, + feeInterest: 0, + quotaRates: { [WETH]: 5 }, + }); + + expect(result.base).toBe(200); + // quota: 100 * 5 = 500; total = 5*200/10 + 500/10; totalOnDebt = 200 + 500/5 + expect(result.quotas).toEqual({ [WETH]: 50 }); + expect(result.total).toBe(150); + expect(result.totalOnDebt).toBe(300); + }); + + it("applies the interest fee to quota rates but not to the truncation parity base", () => { + const result = calcBorrowRate({ + snapshot: snapshot({ + totalDebt: 5n, + totalValue: 10n, + quotas: [{ token: WETH, balance: 100n }], + }), + baseInterestRate, + feeInterest: 500, + quotaRates: { [WETH]: 333 }, + }); + + // base = 200 * 1.05 = 210 + expect(result.base).toBe(210); + // rateBalance = 100 * 333 = 33300; with fee: 33300 * 1.05 = 34965 + expect(result.quotas).toEqual({ [WETH]: 3496 }); + // total = 5*210/10 + 34965/10; totalOnDebt = 210 + 34965/5 + expect(result.total).toBe(105 + 3496); + expect(result.totalOnDebt).toBe(210 + 6993); + }); + + it("reports zero quota contribution for an inactive quota", () => { + const result = calcBorrowRate({ + snapshot: snapshot({ + totalDebt: 5n, + totalValue: 10n, + quotas: [{ token: WETH, balance: 100n }], + }), + baseInterestRate, + feeInterest: 0, + quotaRates: {}, + }); + + expect(result.quotas).toEqual({ [WETH]: 0 }); + expect(result.total).toBe(100); + expect(result.totalOnDebt).toBe(200); + }); + + it("skips leftover quotas at or below the dust threshold", () => { + const result = calcBorrowRate({ + snapshot: snapshot({ + totalDebt: 5n, + totalValue: 10n, + quotas: [{ token: WETH, balance: 10n }], + }), + baseInterestRate, + feeInterest: 0, + quotaRates: { [WETH]: 5 }, + }); + + expect(result.quotas).toEqual({}); + expect(result.total).toBe(100); + expect(result.totalOnDebt).toBe(200); + }); + + it("reports zeros when there is nothing to normalize against", () => { + const result = calcBorrowRate({ + snapshot: snapshot({ + totalDebt: 0n, + totalValue: 0n, + quotas: [{ token: WETH, balance: 100n }], + }), + baseInterestRate, + feeInterest: 0, + quotaRates: { [WETH]: 5 }, + }); + + expect(result).toEqual({ + total: 0, + totalOnDebt: 0, + base: 200, + quotas: { [WETH]: 0 }, + }); + }); +}); diff --git a/src/sdk/positions/calcBorrowRate.ts b/src/sdk/positions/calcBorrowRate.ts new file mode 100644 index 000000000..73bab9b7a --- /dev/null +++ b/src/sdk/positions/calcBorrowRate.ts @@ -0,0 +1,78 @@ +import type { Address } from "viem"; +import type { BorrowRateBreakdown, Bps } from "../../model/index.js"; +import { DUST_THRESHOLD, PERCENTAGE_FACTOR } from "../constants/math.js"; +import { calcBorrowApy } from "../market/math.js"; +import { AddressMap } from "../utils/AddressMap.js"; +import type { AccountSnapshot } from "./types.js"; + +/** + * Inputs of {@link calcBorrowRate}. + **/ +export interface CalcBorrowRateProps { + snapshot: AccountSnapshot; + /** + * Pool base interest rate in ray. + **/ + baseInterestRate: bigint; + /** + * Credit manager interest fee in basis points. + **/ + feeInterest: number; + /** + * Active quota rates in basis points. Missing keys are treated as inactive + * (zero contribution), but a per-token entry is still reported. + **/ + quotaRates: Record; +} + +/** + * Cost of an account state's debt, broken down into the pool's base rate and + * per-token quota rates. + * + * The base rate is the market's current borrow APY (the pool's base rate plus + * the credit manager's interest fee) — the same value `borrowApy` reports on + * a position; it is not recomputed for the projected pool liquidity. Quota + * contributions are `quotaBalance * quotaRate` with the interest fee on top, + * normalized against the total value (`total`, `quotas`) and against the + * debt (`totalOnDebt`, the rate the debt itself grows at). Formulas are in + * parity with the frontend's `BorrowRateUtils`. + **/ +export function calcBorrowRate( + props: CalcBorrowRateProps, +): BorrowRateBreakdown { + const { snapshot, baseInterestRate, feeInterest, quotaRates } = props; + const { quotas, totalDebt, totalValue } = snapshot; + const rates = new AddressMap(Object.entries(quotaRates)); + + const base = calcBorrowApy(baseInterestRate, feeInterest); + const fee = PERCENTAGE_FACTOR + BigInt(feeInterest); + + // Σ balance * rate over active quotas, before the interest fee + let quotaRateSum = 0n; + const perQuota: Record = {}; + for (const q of quotas) { + if (q.balance <= DUST_THRESHOLD) { + continue; + } + const rate = rates.get(q.token); + const rateBalance = rate === undefined ? 0n : q.balance * BigInt(rate); + quotaRateSum += rateBalance; + // per-token contributions carry the fee per token + // (`getSingleQuotaBorrowRate` parity) + const withFee = (rateBalance * fee) / PERCENTAGE_FACTOR; + perQuota[q.token] = totalValue > 0n ? Number(withFee / totalValue) : 0; + } + // the aggregate terms carry the fee once, on the sum + // (`getAverageQuotaBorrowRate` parity) + const quotaRateSumWithFee = (quotaRateSum * fee) / PERCENTAGE_FACTOR; + + const total = + totalValue > 0n + ? Number((totalDebt * BigInt(base)) / totalValue) + + Number(quotaRateSumWithFee / totalValue) + : 0; + const totalOnDebt = + totalDebt > 0n ? base + Number(quotaRateSumWithFee / totalDebt) : 0; + + return { total, totalOnDebt, base, quotas: perQuota }; +} diff --git a/src/sdk/positions/calcHealthFactor.test.ts b/src/sdk/positions/calcHealthFactor.test.ts new file mode 100644 index 000000000..2313f11a1 --- /dev/null +++ b/src/sdk/positions/calcHealthFactor.test.ts @@ -0,0 +1,152 @@ +import type { Address } from "viem"; +import { describe, expect, it } from "vitest"; +import { type Asset, PRICE_DECIMALS_POW, toBN, WAD } from "../index.js"; +import { calcHealthFactor } from "./calcHealthFactor.js"; +import type { AccountSnapshot } from "./types.js"; + +const WETH = + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".toLowerCase() as Address; +const DAI = + "0x6B175474E89094C44Da98b954EedeAC495271d0F".toLowerCase() as Address; +const USDC = + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".toLowerCase() as Address; +const STETH = + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84".toLowerCase() as Address; + +const decimals = { [WETH]: 18, [DAI]: 18, [USDC]: 6, [STETH]: 18 }; +const prices = { + [WETH]: toBN("1738.11830000", PRICE_DECIMALS_POW), + [DAI]: toBN("0.99941103", PRICE_DECIMALS_POW), + [USDC]: toBN("0.999", PRICE_DECIMALS_POW), + [STETH]: toBN("1703.87588096", PRICE_DECIMALS_POW), +}; +const liquidationThresholds = { + [USDC]: 9800, + [DAI]: 9300, + [WETH]: 8500, + [STETH]: 8000, +}; + +const DEFAULT_ASSETS: Asset[] = [ + { balance: toBN("156552", 18), token: DAI }, + { balance: toBN("10", 18), token: WETH }, +]; +const DEFAULT_DEBT = toBN("156552", 18); +const WETH_QUOTA: Asset = { balance: toBN(String(1750 * 10), 18), token: WETH }; + +function snapshot(partial: Partial = {}): AccountSnapshot { + return { + creditManager: DAI, + assets: DEFAULT_ASSETS, + quotas: [], + totalDebt: DEFAULT_DEBT, + totalValue: DEFAULT_DEBT, + ...partial, + }; +} + +function hf( + snap: Partial = {}, + extras: { + activeQuotas?: Record; + prices?: Record; + } = {}, +) { + return calcHealthFactor({ + snapshot: snapshot(snap), + underlying: DAI, + decimals, + prices: extras.prices ?? prices, + liquidationThresholds, + activeQuotas: extras.activeQuotas ?? { [WETH]: true }, + }); +} + +describe("calcHealthFactor", () => { + it("matches the legacy calcHealthFactor numbers", () => { + expect(hf()).toBe(10244); + }); + + it("returns MAX_UINT16 when debt is zero", () => { + expect(hf({ assets: [], totalDebt: 0n, totalValue: 0n })).toBe(65535); + }); + + it("health factor after add collateral matches legacy", () => { + const afterAdd: Asset[] = [ + { balance: toBN("156552", 18), token: DAI }, + { balance: toBN("20", 18), token: WETH }, + ]; + expect(hf({ assets: afterAdd })).toBe(11188); + }); + + it("health factor after decrease debt matches legacy", () => { + const afterDecrease: Asset[] = [ + { balance: toBN("146552", 18), token: DAI }, + { balance: toBN("10", 18), token: WETH }, + ]; + expect(hf({ assets: afterDecrease, totalDebt: toBN("146552", 18) })).toBe( + 10308, + ); + }); + + it("health factor after increase debt matches legacy", () => { + const afterIncrease: Asset[] = [ + { balance: toBN("176552", 18), token: DAI }, + { balance: toBN("10", 18), token: WETH }, + ]; + expect(hf({ assets: afterIncrease, totalDebt: toBN("176552", 18) })).toBe( + 10137, + ); + }); + + it("health factor after swap matches legacy", () => { + const totalMoney = + (DEFAULT_DEBT * WAD * prices[DAI]) / 10n ** 18n / 10n ** 8n; + const wethAmount = + (totalMoney * 10n ** 18n * 10n ** 8n) / prices[WETH] / WAD; + const afterSwap: Asset[] = [ + { balance: toBN("10", 18) + wethAmount, token: WETH }, + ]; + expect(hf({ assets: afterSwap })).toBe(9444); + }); + + it("health factor with sufficient quota matches legacy", () => { + expect(hf({ quotas: [WETH_QUOTA] })).toBe(10244); + }); + + it("health factor with insufficient quota matches legacy", () => { + expect(hf({ quotas: [{ token: WETH, balance: 0n }] })).toBe(9300); + }); + + it("health factor with disabled quota matches legacy", () => { + expect(hf({ quotas: [WETH_QUOTA] }, { activeQuotas: {} })).toBe(9300); + }); + + it("ignores leftover token balances at or below the dust threshold", () => { + const withDust: Asset[] = [ + ...DEFAULT_ASSETS, + { token: STETH, balance: 10n }, + ]; + expect(hf({ assets: withDust })).toBe(hf()); + }); + + it("values the debt when the underlying is priced but not held", () => { + const withoutUnderlying: Asset[] = [ + { balance: toBN("10", 18), token: WETH }, + ]; + expect(hf({ assets: withoutUnderlying })).toBeGreaterThan(0); + expect(hf({ assets: withoutUnderlying })).toBe( + hf({ + assets: [ + { balance: 0n, token: DAI }, + { balance: toBN("10", 18), token: WETH }, + ], + }), + ); + }); + + it("collapses to zero when the underlying has no price", () => { + const { [DAI]: _dai, ...pricesWithoutUnderlying } = prices; + expect(hf({}, { prices: pricesWithoutUnderlying })).toBe(0); + }); +}); diff --git a/src/sdk/positions/calcHealthFactor.ts b/src/sdk/positions/calcHealthFactor.ts new file mode 100644 index 000000000..4bc1a58c0 --- /dev/null +++ b/src/sdk/positions/calcHealthFactor.ts @@ -0,0 +1,103 @@ +import { type Address, isAddressEqual } from "viem"; +import type { Bps } from "../../model/index.js"; +import { + DUST_THRESHOLD, + MAX_UINT16, + PERCENTAGE_FACTOR, +} from "../constants/math.js"; +import { AddressMap } from "../utils/AddressMap.js"; +import { BigIntMath } from "../utils/bigint-math.js"; +import type { AccountSnapshot } from "./types.js"; + +/** + * Inputs of {@link calcHealthFactor}. + **/ +export interface CalcHealthFactorProps { + snapshot: AccountSnapshot; + /** + * Market underlying. Debt and quota balances are valued in this token. + **/ + underlying: Address; + /** + * Token decimals. Missing keys default to 18. + **/ + decimals: Record; + /** + * Oracle prices in 8-decimal (`PRICE_DECIMALS`) fixed point. A missing key + * is an unpriceable token and contributes nothing. + **/ + prices: Record; + /** + * Liquidation thresholds in basis points. Missing keys are treated as 0. + **/ + liquidationThresholds: Record; + /** + * Whether each token's quota is currently active. Missing keys are inactive. + **/ + activeQuotas: Record; +} + +/** + * Health factor of an account state, in basis points (`10000` = 1.0). + * + * Collateral is valued under liquidation thresholds, with quoted tokens + * capped by their quota, and compared against the debt's value. An account + * with no debt reports `65535` (`MAX_UINT16`), the contract's own sentinel + * scaled down. Formulas are in parity with the legacy `calcHealthFactor`. + * Tokens with no price in {@link CalcHealthFactorProps.prices} contribute + * nothing. + **/ +export function calcHealthFactor(props: CalcHealthFactorProps): Bps { + const { + snapshot, + underlying, + decimals, + prices, + liquidationThresholds, + activeQuotas, + } = props; + if (snapshot.totalDebt === 0n) { + return Number(MAX_UINT16); + } + + const decimalsByToken = new AddressMap(Object.entries(decimals)); + const pricesByToken = new AddressMap(Object.entries(prices)); + const lts = new AddressMap(Object.entries(liquidationThresholds)); + const active = new AddressMap(Object.entries(activeQuotas)); + + const convertToUSD = (token: Address, amount: bigint): bigint | null => { + const price = pricesByToken.get(token); + if (price === undefined) { + return null; + } + const scale = 10n ** BigInt(decimalsByToken.get(token) ?? 18); + return (amount * price) / scale; + }; + + const assetMoney = snapshot.assets.reduce((acc, { token, balance }) => { + if (balance <= DUST_THRESHOLD) { + return acc; + } + + const lt = BigInt(lts.get(token) ?? 0); + const tokenLtWeighted = (convertToUSD(token, balance) ?? 0n) * lt; + + const quota = snapshot.quotas.find(q => isAddressEqual(q.token, token)); + const quotaBalance = + quota && (active.get(token) ?? false) ? quota.balance : 0n; + const quotaWeighted = + (convertToUSD(underlying, quotaBalance) ?? 0n) * PERCENTAGE_FACTOR; + + // a token with no quota entry at all is not a quoted token + const money = quota + ? BigIntMath.min(quotaWeighted, tokenLtWeighted) + : tokenLtWeighted; + + return acc + money; + }, 0n); + + const borrowedMoney = convertToUSD(underlying, snapshot.totalDebt) ?? 0n; + const hf = borrowedMoney > 0n ? assetMoney / borrowedMoney : 0n; + + return Number(hf); +} diff --git a/src/sdk/positions/calcLiquidationPrice.test.ts b/src/sdk/positions/calcLiquidationPrice.test.ts new file mode 100644 index 000000000..8bbfd2343 --- /dev/null +++ b/src/sdk/positions/calcLiquidationPrice.test.ts @@ -0,0 +1,81 @@ +import type { Address } from "viem"; +import { describe, expect, it } from "vitest"; +import { type Asset, toBN } from "../index.js"; +import { calcLiquidationPrice } from "./calcLiquidationPrice.js"; +import type { AccountSnapshot } from "./types.js"; + +const WETH = + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".toLowerCase() as Address; +const DAI = + "0x6B175474E89094C44Da98b954EedeAC495271d0F".toLowerCase() as Address; +const USDC = + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".toLowerCase() as Address; +const STETH = + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84".toLowerCase() as Address; + +const decimals = { [WETH]: 18, [DAI]: 18, [USDC]: 6, [STETH]: 18 }; +const liquidationThresholds = { + [USDC]: 9800, + [DAI]: 9300, + [WETH]: 8500, + [STETH]: 8000, +}; + +const LP_ASSETS: Asset[] = [ + { token: USDC, balance: toBN("10000", 6) }, + { token: WETH, balance: toBN("25", 18) }, +]; + +function snapshot(partial: Partial = {}): AccountSnapshot { + return { + creditManager: USDC, + assets: LP_ASSETS, + quotas: [], + totalDebt: toBN("40000", 6), + totalValue: toBN("40000", 6), + ...partial, + }; +} + +function price(snap: Partial = {}) { + return calcLiquidationPrice({ + snapshot: snapshot(snap), + underlying: USDC, + decimals, + liquidationThresholds, + }); +} + +describe("calcLiquidationPrice", () => { + it("computes the price for a single non-underlying target", () => { + // effectiveDebt = (40000e6 - 10000e6 * 0.98) * 1e12 = 30200e6 * 1e12 + // price = effectiveDebt * 1e8 * 10000 / (25e18 * 8500) + const effectiveDebt = (toBN("40000", 6) - toBN("9800", 6)) * 10n ** 12n; + const expected = + (effectiveDebt * 10n ** 8n * 10000n) / (toBN("25", 18) * 8500n); + expect(price()).toBe(expected); + expect(expected).toBeGreaterThan(0n); + }); + + it("returns null with zero non-underlying assets", () => { + expect( + price({ assets: [{ token: USDC, balance: toBN("10000", 6) }] }), + ).toBe(null); + }); + + it("ignores leftover non-underlying when picking the target", () => { + expect( + price({ + assets: [...LP_ASSETS, { token: STETH, balance: 10n }], + }), + ).toBe(price()); + }); + + it("returns null with two non-underlying assets", () => { + expect( + price({ + assets: [...LP_ASSETS, { token: STETH, balance: toBN("5", 18) }], + }), + ).toBe(null); + }); +}); diff --git a/src/sdk/positions/calcLiquidationPrice.ts b/src/sdk/positions/calcLiquidationPrice.ts new file mode 100644 index 000000000..cb85a4545 --- /dev/null +++ b/src/sdk/positions/calcLiquidationPrice.ts @@ -0,0 +1,29 @@ +import { isAddressEqual } from "viem"; +import { DUST_THRESHOLD } from "../constants/math.js"; +import { + type CalcLiquidationPriceProps, + calcLiquidationPriceForTarget, +} from "./calcLiquidationPriceForTarget.js"; + +/** + * Liquidation price of an account state's target collateral, in the oracle's + * 8-decimal (`PRICE_DECIMALS`) fixed point. + * + * As the frontend does, a liquidation price only exists when the account + * holds exactly one non-dust non-underlying asset; otherwise `null`. + **/ +export function calcLiquidationPrice( + props: CalcLiquidationPriceProps, +): bigint | null { + const { snapshot, underlying } = props; + const targets = snapshot.assets.filter( + a => a.balance > DUST_THRESHOLD && !isAddressEqual(a.token, underlying), + ); + if (targets.length !== 1) { + return null; + } + return calcLiquidationPriceForTarget({ + ...props, + targetToken: targets[0].token, + }); +} diff --git a/src/sdk/positions/calcLiquidationPriceForTarget.test.ts b/src/sdk/positions/calcLiquidationPriceForTarget.test.ts new file mode 100644 index 000000000..0c210c226 --- /dev/null +++ b/src/sdk/positions/calcLiquidationPriceForTarget.test.ts @@ -0,0 +1,86 @@ +import type { Address } from "viem"; +import { describe, expect, it } from "vitest"; +import { type Asset, toBN } from "../index.js"; +import { calcLiquidationPriceForTarget } from "./calcLiquidationPriceForTarget.js"; +import type { AccountSnapshot } from "./types.js"; + +const WETH = + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".toLowerCase() as Address; +const DAI = + "0x6B175474E89094C44Da98b954EedeAC495271d0F".toLowerCase() as Address; +const USDC = + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".toLowerCase() as Address; +const STETH = + "0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84".toLowerCase() as Address; + +const decimals = { [WETH]: 18, [DAI]: 18, [USDC]: 6, [STETH]: 18 }; +const liquidationThresholds = { + [USDC]: 9800, + [DAI]: 9300, + [WETH]: 8500, + [STETH]: 8000, +}; + +const LP_ASSETS: Asset[] = [ + { token: USDC, balance: toBN("10000", 6) }, + { token: WETH, balance: toBN("25", 18) }, +]; + +function snapshot(partial: Partial = {}): AccountSnapshot { + return { + creditManager: USDC, + assets: LP_ASSETS, + quotas: [], + totalDebt: toBN("40000", 6), + totalValue: toBN("40000", 6), + ...partial, + }; +} + +function priceFor( + targetToken: Address, + snap: Partial = {}, + extras: { decimals?: Record } = {}, +) { + return calcLiquidationPriceForTarget({ + snapshot: snapshot(snap), + targetToken, + underlying: USDC, + decimals: extras.decimals ?? decimals, + liquidationThresholds, + }); +} + +describe("calcLiquidationPriceForTarget", () => { + it("computes the price for an explicit target", () => { + const effectiveDebt = (toBN("40000", 6) - toBN("9800", 6)) * 10n ** 12n; + const expected = + (effectiveDebt * 10n ** 8n * 10000n) / (toBN("25", 18) * 8500n); + expect(priceFor(WETH)).toBe(expected); + }); + + it("returns 0n when the account holds none of the target", () => { + expect( + priceFor(WETH, { assets: [{ token: USDC, balance: toBN("10000", 6) }] }), + ).toBe(0n); + }); + + it("returns 0n when the target has no liquidation threshold", () => { + expect( + calcLiquidationPriceForTarget({ + snapshot: snapshot({ + assets: [...LP_ASSETS, { token: STETH, balance: toBN("5", 18) }], + }), + targetToken: STETH, + underlying: USDC, + decimals, + liquidationThresholds: { [USDC]: 9800, [WETH]: 8500 }, + }), + ).toBe(0n); + }); + + it("uses underlying decimals rather than falling back to 18", () => { + const withWrongFallback = priceFor(WETH, {}, { decimals: { [WETH]: 18 } }); + expect(priceFor(WETH)).not.toBe(withWrongFallback); + }); +}); diff --git a/src/sdk/positions/calcLiquidationPriceForTarget.ts b/src/sdk/positions/calcLiquidationPriceForTarget.ts new file mode 100644 index 000000000..7cfa0b7c1 --- /dev/null +++ b/src/sdk/positions/calcLiquidationPriceForTarget.ts @@ -0,0 +1,89 @@ +import { type Address, isAddressEqual } from "viem"; +import type { Bps } from "../../model/index.js"; +import { + DUST_THRESHOLD, + PERCENTAGE_FACTOR, + PRICE_DECIMALS, + WAD, +} from "../constants/math.js"; +import { AddressMap } from "../utils/AddressMap.js"; +import type { AccountSnapshot } from "./types.js"; + +/** + * Shared market-side inputs of a liquidation-price calculation. + **/ +export interface CalcLiquidationPriceProps { + snapshot: AccountSnapshot; + /** + * Market underlying. Its balance under its LT is subtracted from the debt. + **/ + underlying: Address; + /** + * Token decimals. Missing keys default to 18. + **/ + decimals: Record; + /** + * Liquidation thresholds in basis points. Missing keys are treated as 0. + **/ + liquidationThresholds: Record; +} + +/** + * Inputs of {@link calcLiquidationPriceForTarget}. + **/ +export interface CalcLiquidationPriceForTargetProps + extends CalcLiquidationPriceProps { + /** + * Collateral token whose liquidation price to compute. + **/ + targetToken: Address; +} + +/** + * Liquidation price of an explicitly named collateral token, in + * `PRICE_DECIMALS` fixed point; `0n` when the account holds none of it or the + * token has no liquidation threshold. Formula is in parity with the legacy + * `liquidationPrice`: the effective debt (debt less the underlying balance's + * contribution under its threshold) over the threshold-weighted target + * balance. + **/ +export function calcLiquidationPriceForTarget( + props: CalcLiquidationPriceForTargetProps, +): bigint { + const { snapshot, targetToken, underlying, decimals, liquidationThresholds } = + props; + const decimalsByToken = new AddressMap(Object.entries(decimals)); + const lts = new AddressMap(Object.entries(liquidationThresholds)); + + const underlyingDecimals = decimalsByToken.get(underlying) ?? 18; + const underlyingBalance = + snapshot.assets.find(a => isAddressEqual(a.token, underlying))?.balance ?? + 0n; + + // effectiveDebt = Debt - underlyingBalance*LTunderlying + const ltUnderlying = BigInt(lts.get(underlying) ?? 0); + const effectiveDebt = + ((snapshot.totalDebt - + (underlyingBalance * ltUnderlying) / PERCENTAGE_FACTOR) * + WAD) / + 10n ** BigInt(underlyingDecimals); + + const targetDecimals = decimalsByToken.get(targetToken) ?? 18; + const targetBalance = + snapshot.assets.find(a => isAddressEqual(a.token, targetToken))?.balance ?? + 0n; + const effectiveTargetBalance = + (targetBalance * WAD) / 10n ** BigInt(targetDecimals); + + const lpLT = BigInt(lts.get(targetToken) ?? 0); + + if (targetBalance <= DUST_THRESHOLD || lpLT <= 0n) { + return 0n; + } + + // priceTarget = effectiveDebt / (lpLT*targetBalance) + return ( + (effectiveDebt * PRICE_DECIMALS * PERCENTAGE_FACTOR) / + (effectiveTargetBalance * lpLT) + ); +} diff --git a/src/sdk/positions/calcTimeToLiquidationMs.test.ts b/src/sdk/positions/calcTimeToLiquidationMs.test.ts new file mode 100644 index 000000000..206b665cc --- /dev/null +++ b/src/sdk/positions/calcTimeToLiquidationMs.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { calcTimeToLiquidationMs } from "./calcTimeToLiquidationMs.js"; + +describe("calcTimeToLiquidationMs", () => { + it("matches the legacy getTimeToLiquidation numbers", () => { + expect(calcTimeToLiquidationMs(9000, 250n)).toBe(null); + expect(calcTimeToLiquidationMs(9000, 0n)).toBe(null); + expect(calcTimeToLiquidationMs(13750, 20n * 10000n)).toBe( + 59130000n * 1000n, + ); + }); + + it("returns null when the debt carries no rate", () => { + expect(calcTimeToLiquidationMs(10244, 0n)).toBe(null); + }); +}); diff --git a/src/sdk/positions/calcTimeToLiquidationMs.ts b/src/sdk/positions/calcTimeToLiquidationMs.ts new file mode 100644 index 000000000..ba4a8de3e --- /dev/null +++ b/src/sdk/positions/calcTimeToLiquidationMs.ts @@ -0,0 +1,34 @@ +import type { Bps } from "../../model/index.js"; +import { + PERCENTAGE_DECIMALS, + PERCENTAGE_FACTOR, + SECONDS_PER_YEAR, +} from "../constants/math.js"; + +/** + * Estimated milliseconds until `healthFactorBps` decays to `10000` (1.0) + * while the debt grows at `totalBorrowRateOnDebt` (basis points relative to + * the debt, as {@link BorrowRateBreakdown.totalOnDebt} reports it). + * + * `null` when the account is already at or under the liquidation threshold, + * or when the debt carries no borrow rate at all. Formula is in parity with + * the legacy `getTimeToLiquidation`. + **/ +export function calcTimeToLiquidationMs( + healthFactorBps: Bps, + totalBorrowRateOnDebt: bigint, +): bigint | null { + if ( + BigInt(healthFactorBps) <= PERCENTAGE_FACTOR || + totalBorrowRateOnDebt === 0n + ) { + return null; + } + + // (HF - 1) / (br_D / year) or (HF - 1) * (year / br_D) + const HF_1 = BigInt(healthFactorBps) - PERCENTAGE_FACTOR; + const brPerYear = + (BigInt(SECONDS_PER_YEAR) * PERCENTAGE_FACTOR * PERCENTAGE_DECIMALS) / + totalBorrowRateOnDebt; + return (HF_1 * brPerYear * 1000n) / PERCENTAGE_FACTOR; +} diff --git a/src/sdk/positions/index.ts b/src/sdk/positions/index.ts index f9783c134..d6c011bce 100644 --- a/src/sdk/positions/index.ts +++ b/src/sdk/positions/index.ts @@ -1,3 +1,8 @@ +export * from "./calcBorrowRate.js"; +export * from "./calcHealthFactor.js"; +export * from "./calcLiquidationPrice.js"; +export * from "./calcLiquidationPriceForTarget.js"; +export * from "./calcTimeToLiquidationMs.js"; export * from "./MultichainPositionsService.js"; export * from "./PositionsService.js"; export * from "./types.js"; diff --git a/src/sdk/positions/types.ts b/src/sdk/positions/types.ts index 9c5704e17..c7a4213e0 100644 --- a/src/sdk/positions/types.ts +++ b/src/sdk/positions/types.ts @@ -1,5 +1,7 @@ import type { Address } from "viem"; import type { PositionFilter } from "../../model/index.js"; +import type { Asset, CreditAccountData } from "../base/index.js"; +import { DUST_THRESHOLD } from "../constants/math.js"; import type { BlockNumberProps, WithBlock } from "../types/index.js"; /** @@ -30,3 +32,80 @@ export interface ListPositionsPropsBase { **/ export type ListPositionsProps = ListPositionsPropsBase & WithBlock; + +/** + * Props for {@link PositionsService.listStrategyPositions}. + **/ +export interface ListStrategyPositionsProps { + /** + * Wallet whose credit accounts to describe. RWA accounts are resolved from + * the investor EOA, see {@link ICreditAccountsService.getBorrowerCreditAccounts}. + **/ + owner: Address; + /** + * Whether to include accounts that carry no debt. + **/ + includeZeroDebt: boolean; + /** + * Block to read at. Defaults to the latest block. + **/ + blockNumber?: bigint; +} + +/** + * The one input every position-metric function takes: a credit account's + * state — its credit manager, token balances, quota holdings, total debt and + * total value in the market's underlying — actual or projected. + * + * Everything else (decimals, prices, liquidation thresholds, quota rates, + * the pool's base rate) is supplied at the calculation site. + **/ +export interface AccountSnapshot { + /** + * Credit manager the account is (or will be) opened in. + **/ + creditManager: Address; + /** + * Token balances of the account. + **/ + assets: Asset[]; + /** + * Quota holdings of the account: quota balances are denominated in the + * market's underlying. + **/ + quotas: Asset[]; + /** + * Debt principal plus accrued interest and fees, in underlying. + **/ + totalDebt: bigint; + /** + * Total account value in underlying. + **/ + totalValue: bigint; +} + +/** + * Builds an {@link AccountSnapshot} from on-chain credit account data: the + * enabled, above-dust tokens become assets and quotas, and `totalDebt` is + * principal plus accrued interest and fees. + **/ +export function accountSnapshotFromCreditAccountData( + ca: CreditAccountData, +): AccountSnapshot { + const assets: Asset[] = []; + const quotas: Asset[] = []; + for (const t of ca.tokens) { + if ((t.mask & ca.enabledTokensMask) === 0n || t.balance <= DUST_THRESHOLD) { + continue; + } + assets.push({ token: t.token, balance: t.balance }); + quotas.push({ token: t.token, balance: t.quota }); + } + return { + creditManager: ca.creditManager, + assets, + quotas, + totalDebt: ca.debt + ca.accruedInterest + ca.accruedFees, + totalValue: ca.totalValue, + }; +}