= {
+ [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,
+ };
+}