@1delta/irm-sdk
v0.0.8
Published
Unified interest rate model SDK for all lending protocols
Readme
@1delta/irm-sdk
Unified interest rate model SDK for DeFi lending protocols. Supports Aave, Compound V2, Compound V3, Morpho, and Euler.
Installation
pnpm add @1delta/irm-sdkArchitecture
The SDK separates concerns into three layers:
Fetchers (on-chain data) → Models (pure math) → Impact (rate simulations)- Fetchers read raw on-chain state via multicall — current rates, totals, and optionally static IRM parameters (slopes, kinks, reserve factors).
- Models compute rates from parameters as pure functions — no RPC, no side effects.
- Impact simulates how user actions (deposit, borrow, withdraw, repay, leverage) shift rates.
IRM parameters fall into two categories:
| Category | Examples | Update frequency | | ----------- | --------------------------------------------------------- | ------------------------- | | Static | Slopes, kinks, reserve factors, strategy addresses | Rarely (governance votes) | | Dynamic | Total deposits, total borrows, utilization, current rates | Every block |
Static data can be fetched once, stored (JSON file, database, git repo), and reused across many dynamic fetches to reduce RPC calls.
1. Fetching On-Chain Data
Quick start: single lender
import { fetchIRM } from '@1delta/irm-sdk'
const result = await fetchIRM({ chainId: '1', lender: 'AAVE_V3' })
for (const [asset, data] of Object.entries(result.assets)) {
console.log(asset, data.currentState)
// { totalDeposits, totalDebt, utilization, borrowRate, depositRate }
}Per-protocol fetchers
import {
fetchAaveIRM,
fetchCompoundV3IRM,
fetchMorphoIRM,
fetchEulerIRM,
} from '@1delta/irm-sdk'
import { multicallRetryUniversal } from '@1delta/providers'
const result = await fetchAaveIRM('1', 'AAVE_V3', multicallRetryUniversal)Custom multicall / RPC overrides
const result = await fetchIRM({
chainId: '1',
lender: 'AAVE_V3',
multicall: myCustomMulticall,
rpcOverrides: { '1': ['https://my-rpc.example.com'] },
})Batching multiple lenders
Use the prepare/parse pattern to combine multiple lenders into a single multicall:
import { prepareFetch, batchPrepared } from '@1delta/irm-sdk'
import { multicallRetryUniversal } from '@1delta/providers'
const lenders = ['AAVE_V3', 'COMPOUND_V3', 'MORPHO_BLUE']
const prepared = lenders
.map((lender) => prepareFetch({ chainId: '1', lender }))
.filter(Boolean)
const results = await batchPrepared('1', prepared, multicallRetryUniversal)
// results[i] corresponds to prepared[i]2. Static vs Dynamic Data
Overview
| Protocol | Phases | Static fetcher | Phase functions | Output type |
|----------|--------|----------------|-----------------|-------------|
| Aave (all forks) | 2 | fetchAaveStatic | fetchAaveStaticPhase1, fetchAaveStaticPhase2 | AaveStaticData |
| Compound V2 (all forks) | 2 | fetchCompoundV2Static | fetchCompoundV2StaticPhase1, fetchCompoundV2StaticPhase2 | CompoundV2StaticData |
| Compound V3 | 1 | fetchCompoundV3Static | — | CompoundV3StaticData |
| Euler | 1 | fetchEulerStatic | — | EulerStaticData |
| Morpho | 1 | fetchMorphoStatic | — | MorphoStaticData |
All functions share the same base signature:
(chainId: string, lender: string, multicall: MulticallFn, rpcOverrides?: Record<string, string[]>) => Promise<T | null>Phase reference
Aave — 2 phases (phase 2 depends on phase 1)
Phase 1 — discover strategy addresses + reserve factors
fetchAaveStaticPhase1(
chainId: string,
lender: string,
multicall: MulticallFn,
rpcOverrides?: Record<string, string[]>,
): Promise<AaveStaticPhase1Result | null>Reads from the ProtocolDataProvider contract per reserve asset:
getReserveConfigurationData→reserveFactor(basis points → 0-1 fraction)getInterestRateStrategyAddress→strategyAddress
Returns:
interface AaveStaticPhase1Result {
/** Per-asset config, keyed by asset address */
assets: Record<string, AaveAssetConfig>
/** Unique strategy address → first reserve address using it (input for phase 2) */
strategyToAsset: Record<string, string>
}
interface AaveAssetConfig {
strategyAddress: string // InterestRateStrategy contract address
reserveFactor: number // 0-1 fraction (e.g. 0.1 = 10%)
}Phase 2 — fetch IRM params from strategy contracts
fetchAaveStaticPhase2(
chainId: string,
strategyToAsset: Record<string, string>, // ← from phase 1
multicall: MulticallFn,
rpcOverrides?: Record<string, string[]>,
): Promise<AaveStaticPhase2Result>Reads from each unique InterestRateStrategy contract:
getBaseVariableBorrowRate,getVariableRateSlope1,getVariableRateSlope2getMaxVariableBorrowRate,OPTIMAL_USAGE_RATIO
Returns:
interface AaveStaticPhase2Result {
/** IRM params per unique strategy contract address */
strategies: Record<string, AaveIRMParams>
}
interface AaveIRMParams {
baseVariableBorrowRate: number // APR %
variableRateSlope1: number // APR % below optimal
variableRateSlope2: number // APR % above optimal
optimalUsageRatio: number // 0-1 fraction (e.g. 0.8 = 80%)
maxRate?: number // APR % maximum rate cap
reserveFactor?: number // populated when combined with asset config
}Combined (fetchAaveStatic runs both phases sequentially):
interface AaveStaticData {
assets: Record<string, AaveAssetConfig> // from phase 1
strategies: Record<string, AaveIRMParams> // from phase 2
}Compound V2 — 2 phases (phase 2 depends on phase 1)
Phase 1 — discover IRM addresses + reserve factors
fetchCompoundV2StaticPhase1(
chainId: string,
lender: string,
multicall: MulticallFn,
rpcOverrides?: Record<string, string[]>,
): Promise<CompoundV2StaticPhase1Result | null>Reads from each cToken contract:
interestRateModel→ IRM contract addressreserveFactorMantissa→ reserve factor (WAD → 0-1 fraction)
Returns:
interface CompoundV2StaticPhase1Result {
/** Per-cToken config, keyed by cToken address */
tokens: Record<string, CompoundV2TokenConfig>
/** Unique IRM contract addresses (input for phase 2) */
uniqueIRMs: string[]
}
interface CompoundV2TokenConfig {
irmAddress: string // JumpRateModel contract address
reserveFactor: number // 0-1 fraction (e.g. 0.2 = 20%)
}Phase 2 — fetch JumpRate params from IRM contracts
fetchCompoundV2StaticPhase2(
chainId: string,
irmAddresses: string[], // ← uniqueIRMs from phase 1
multicall: MulticallFn,
rpcOverrides?: Record<string, string[]>,
): Promise<CompoundV2StaticPhase2Result>Reads from each unique JumpRateModel contract:
baseRatePerBlock,multiplierPerBlock,jumpMultiplierPerBlock,kink
Returns:
interface CompoundV2StaticPhase2Result {
/** JumpRate params per unique IRM contract address */
irmParams: Record<string, CompoundV2JumpRateParams>
}
interface CompoundV2JumpRateParams {
baseRatePerBlock: bigint // 18-decimal
multiplierPerBlock: bigint // 18-decimal slope below kink
jumpMultiplierPerBlock: bigint // 18-decimal slope above kink
kink: bigint // 18-decimal (e.g. 0.8e18 = 80%)
blockTimeSeconds: number // chain block time (e.g. 3 for BNB)
}Combined (fetchCompoundV2Static runs both phases sequentially):
interface CompoundV2StaticData {
tokens: Record<string, CompoundV2TokenConfig> // from phase 1
irmParams: Record<string, CompoundV2JumpRateParams> // from phase 2
}Compound V3 — 1 phase
fetchCompoundV3Static(chainId, lender, multicall, rpcOverrides?)
→ CompoundV3StaticData | nullReads 8 parameters directly from the Comet contract in a single multicall.
interface CompoundV3StaticData {
curveParams: CompoundV3CurveParams
}
interface CompoundV3CurveParams {
borrowKink: bigint // 18-decimal
borrowPerSecondInterestRateBase: bigint
borrowPerSecondInterestRateSlopeLow: bigint
borrowPerSecondInterestRateSlopeHigh: bigint
supplyKink: bigint // 18-decimal
supplyPerSecondInterestRateBase: bigint
supplyPerSecondInterestRateSlopeLow: bigint
supplyPerSecondInterestRateSlopeHigh: bigint
}Euler — 1 phase
fetchEulerStatic(chainId, lender, multicall, rpcOverrides?)
→ EulerStaticData | nullCalls VaultLens.getVaultInfoFull per vault and extracts only the static IRM fields. Only LinearKink (type 2) vaults are included; other IRM types are omitted.
interface EulerStaticData {
/** Per-vault IRM config, keyed by vault address */
vaults: Record<string, EulerVaultConfig>
}
interface EulerVaultConfig {
underlying: string // underlying asset address
interestFee: number // 0-1 fraction (protocol fee on borrow interest)
modelType: number // IRM type (2 = LinearKink)
params: EulerLinearKinkParams
}
interface EulerLinearKinkParams {
baseRate: bigint // per-second, scaled by 1e27
slope1: bigint // per-second, scaled by 1e27
slope2: bigint // per-second, scaled by 1e27
kink: bigint // scaled by uint32.max (4294967295)
}Morpho — 1 phase
fetchMorphoStatic(chainId, lender, multicall, rpcOverrides?)
→ MorphoStaticData | nullCalls the Morpho lens contract and extracts only the governance-set fee
per market.
Note:
rateAtTargetis NOT static — it adapts dynamically based on utilization history. Onlyfeeis a true static parameter.
interface MorphoStaticData {
/** Per-market fee config, keyed by market ID (bytes32) */
markets: Record<string, MorphoMarketConfig>
}
interface MorphoMarketConfig {
loanToken: string // loan token address
fee: bigint // WAD-scaled (e.g. 0.1e18 = 10% protocol fee)
}Fetching static data — combined (all phases)
import {
fetchAaveStatic,
fetchCompoundV2Static,
fetchCompoundV3Static,
fetchEulerStatic,
fetchMorphoStatic,
} from '@1delta/irm-sdk'
import { multicallRetryUniversal } from '@1delta/providers'
const aaveStatic = await fetchAaveStatic('1', 'AAVE_V3', multicallRetryUniversal)
const compV2Static = await fetchCompoundV2Static('56', 'VENUS', multicallRetryUniversal)
const compV3Static = await fetchCompoundV3Static('1', 'COMPOUND_V3_USDC', multicallRetryUniversal)
const eulerStatic = await fetchEulerStatic('1', 'EULER_V2', multicallRetryUniversal)
const morphoStatic = await fetchMorphoStatic('1', 'MORPHO_BLUE', multicallRetryUniversal)Fetching static data — individual phases
For RPC environments with rate limits, run each phase separately with delays or different providers between phases:
import {
fetchAaveStaticPhase1,
fetchAaveStaticPhase2,
fetchCompoundV2StaticPhase1,
fetchCompoundV2StaticPhase2,
fetchCompoundV3Static,
fetchEulerStatic,
fetchMorphoStatic,
} from '@1delta/irm-sdk'
// --- Aave (2 phases) ---
// Phase 1: ProtocolDataProvider → strategy addresses + reserve factors
const aaveP1 = await fetchAaveStaticPhase1('1', 'AAVE_V3', multicall)
// => { assets: Record<address, { strategyAddress, reserveFactor }>,
// strategyToAsset: Record<strategyAddr, firstReserveAddr> }
await delay(2000) // rate-limit pause, switch RPC, etc.
// Phase 2: InterestRateStrategy contracts → IRM params
// Input: aaveP1.strategyToAsset (from phase 1)
const aaveP2 = await fetchAaveStaticPhase2('1', aaveP1.strategyToAsset, multicall)
// => { strategies: Record<strategyAddr, AaveIRMParams> }
// Combine → AaveStaticData
const aaveStatic = { assets: aaveP1.assets, strategies: aaveP2.strategies }
// --- Compound V2 (2 phases) ---
// Phase 1: cTokens → IRM addresses + reserve factors
const cv2P1 = await fetchCompoundV2StaticPhase1('56', 'VENUS', multicall)
// => { tokens: Record<cTokenAddr, { irmAddress, reserveFactor }>,
// uniqueIRMs: string[] }
await delay(2000)
// Phase 2: JumpRateModel contracts → rate params
// Input: cv2P1.uniqueIRMs (from phase 1)
const cv2P2 = await fetchCompoundV2StaticPhase2('56', cv2P1.uniqueIRMs, multicall)
// => { irmParams: Record<irmAddr, CompoundV2JumpRateParams> }
// Combine → CompoundV2StaticData
const compV2Static = { tokens: cv2P1.tokens, irmParams: cv2P2.irmParams }
// --- Single-phase protocols (no phase split needed) ---
const compV3Static = await fetchCompoundV3Static('1', 'COMPOUND_V3_USDC', multicall)
const eulerStatic = await fetchEulerStatic('1', 'EULER_V2', multicall)
const morphoStatic = await fetchMorphoStatic('1', 'MORPHO_BLUE', multicall)GitHub Actions integration
The phase separation is designed for a periodic GitHub Action that fetches and stores static IRM parameters:
┌─ GitHub Action (cron) ────────────────────────────────────────────┐
│ │
│ for each (lender, chainId): │
│ if aave-type: │
│ p1 = fetchAaveStaticPhase1(chainId, lender, multicall) │
│ delay(...) │
│ p2 = fetchAaveStaticPhase2(chainId, p1.strategyToAsset, mc) │
│ store { assets: p1.assets, strategies: p2.strategies } │
│ if compound-v2-type: │
│ p1 = fetchCompoundV2StaticPhase1(chainId, lender, mc) │
│ delay(...) │
│ p2 = fetchCompoundV2StaticPhase2(chainId, p1.uniqueIRMs, mc) │
│ store { tokens: p1.tokens, irmParams: p2.irmParams } │
│ if compound-v3-type: │
│ store fetchCompoundV3Static(chainId, lender, multicall) │
│ if euler-type: │
│ store fetchEulerStatic(chainId, lender, multicall) │
│ if morpho-type: │
│ store fetchMorphoStatic(chainId, lender, multicall) │
│ │
│ git commit + push JSON files │
└───────────────────────────────────────────────────────────────────┘Phase dependency graph:
Aave: Phase1 ──► strategyToAsset ──► Phase2 ──► AaveStaticData
Compound V2: Phase1 ──► uniqueIRMs ──────► Phase2 ──► CompoundV2StaticData
Compound V3: (single call) ──────────────────────────► CompoundV3StaticData
Euler: (single call) ──────────────────────────► EulerStaticData
Morpho: (single call) ──────────────────────────► MorphoStaticDataBigInt serialization:
CompoundV2JumpRateParams,CompoundV3CurveParams,EulerLinearKinkParams, andMorphoMarketConfigcontainbigintfields. Use a custom JSON replacer/reviver (e.g.bigint → string) when storing to files.
Unified static fetcher
import { fetchStatic } from '@1delta/irm-sdk'
// Dispatch by protocol type — runs all phases combined
const data = await fetchStatic('1', 'AAVE_V3', 'aave', multicallRetryUniversal)
const euler = await fetchStatic('1', 'EULER_V2', 'euler', multicallRetryUniversal)
const morpho = await fetchStatic('1', 'MORPHO_BLUE', 'morpho', multicallRetryUniversal)Using pre-fetched static data
Pass static data to fetchers to skip redundant on-chain reads:
import { prepareFetch, batchPrepared } from '@1delta/irm-sdk'
import type { AaveStaticData } from '@1delta/irm-sdk'
import fs from 'fs'
// Load static data from file (fetched by a GitHub Action, cron job, etc.)
const aaveStatic: AaveStaticData = JSON.parse(
fs.readFileSync('data/aave-v3-mainnet.json', 'utf8'),
)
// Now only dynamic calls (current rates, totals) are made
const prepared = prepareFetch({
chainId: '1',
lender: 'AAVE_V3',
staticData: { aave: aaveStatic },
})
const [result] = await batchPrepared('1', [prepared!], multicall)Recommended storage workflow
GitHub Action (periodic) ──► fetch static IRM params ──► commit to data repo (JSON)
│
Consumer service ──► read static JSON ──► pass to fetchers ──► fetch dynamic state ──► store in DB- Static params repo: a GitHub Action periodically reads on-chain static IRM params and commits them as JSON files.
- Consumer service: imports
@1delta/irm-sdk, loads static JSON, calls fetchers withstaticDatato get only dynamic state, then writes results to a database.
3. Parsing Fetch Results
Every fetcher returns a FetchIRMResult:
interface FetchIRMResult {
chainId: string
lender: string
poolAddress?: string
assets: Record<string, AssetIRMResult>
}
interface AssetIRMResult {
asset: string // asset address
currentState: {
totalDeposits: number // normalized (not raw decimals)
totalDebt: number
utilization: number // 0 to 1
borrowRate: number // APR %
depositRate: number // APR %
}
modelParams?: IRMParams // protocol-specific IRM parameters
curve?: RateCurve // piecewise-linear borrow rate curve
timestamp: number // fetch time (ms)
}Example: extract and store data from results:
const result = await fetchIRM({ chainId: '1', lender: 'AAVE_V3' })
const rows = Object.entries(result.assets).map(([asset, data]) => ({
chainId: result.chainId,
lender: result.lender,
asset,
...data.currentState,
timestamp: data.timestamp,
// Optional: serialize model params for later rate simulation
modelParams: data.modelParams ? JSON.stringify(data.modelParams) : null,
}))
// Insert rows into your database
await db.insertMany('irm_snapshots', rows)4. Rate Calculation (Models)
Compute rates from parameters — pure math, no RPC:
import { calculateRates, buildCurve } from '@1delta/irm-sdk'
// Calculate rates at a given utilization
const { borrowRate, depositRate } = calculateRates(0.8, {
model: 'aave',
baseVariableBorrowRate: 0,
variableRateSlope1: 4,
variableRateSlope2: 60,
optimalUsageRatio: 0.8,
})
// Build a full rate curve (array of utilization/rate points)
const curve = buildCurve({
model: 'aave',
baseVariableBorrowRate: 0,
variableRateSlope1: 4,
variableRateSlope2: 60,
optimalUsageRatio: 0.8,
})
// curve.kinks = [{ utilization: 0, rate: 0 }, ..., { utilization: 1, rate: 64 }]Per-model namespaces
import { aave, compoundV2, compoundV3, euler } from '@1delta/irm-sdk'
aave.buildKinks(params)
aave.calculateRates(utilization, params)
compoundV3.calculateCurveRates(utilization, params)
euler.calculateLinearKinkRates(utilization, params)Using fetched modelParams directly
const result = await fetchIRM({ chainId: '1', lender: 'AAVE_V3' })
for (const [asset, data] of Object.entries(result.assets)) {
if (data.modelParams) {
// Calculate what the rate would be at 90% utilization
const rates = calculateRates(0.9, { model: 'aave', ...data.modelParams })
console.log(`${asset} at 90% util: borrow=${rates.borrowRate}%`)
}
}5. Rate Impact / Shift Simulation
Simulate how user actions affect pool rates:
import {
computeRateShift,
computeDepositShift,
computeBorrowShift,
computeWithdrawShift,
computeRepayShift,
} from '@1delta/irm-sdk'
import type { PoolState, RateFn } from '@1delta/irm-sdk'
// Pool state from a fetch result
const pool: PoolState = {
totalDeposits: 1_000_000,
totalDebt: 500_000,
}
// Rate function from model params
const rateFn: RateFn = (u) => calculateRates(u, { model: 'aave', ...params })
// Simulate specific actions
const depositShift = computeDepositShift(pool, rateFn, 100_000)
const borrowShift = computeBorrowShift(pool, rateFn, 50_000)
const withdrawShift = computeWithdrawShift(pool, rateFn, 100_000)
const repayShift = computeRepayShift(pool, rateFn, 50_000)
// General shift with arbitrary deltas
const shift = computeRateShift(pool, rateFn, {
depositDelta: 100_000,
debtDelta: -50_000,
})
// => { oldUtilization, newUtilization, oldBorrowRate, newBorrowRate, oldDepositRate, newDepositRate }Batch impact scenarios
import {
computeImpact,
DEFAULT_DEPOSIT_AMOUNTS,
DEFAULT_BORROW_AMOUNTS,
} from '@1delta/irm-sdk'
const impacts = computeImpact(pool, rateFn, {
depositAmounts: DEFAULT_DEPOSIT_AMOUNTS,
borrowAmounts: DEFAULT_BORROW_AMOUNTS,
})
// impacts.deposit => RateImpact[] (one per deposit amount)
// impacts.borrow => RateImpact[]
// impacts.withdraw => RateImpact[]
// impacts.repay => RateImpact[]Leverage scenarios
import {
computeLeverageOpenShift,
computeLeverageCloseShift,
} from '@1delta/irm-sdk'
// Open leverage: deposit collateral + borrow simultaneously
const openShift = computeLeverageOpenShift(pool, rateFn, {
depositAmount: 100_000,
borrowAmount: 80_000,
})
// Close leverage: withdraw collateral + repay simultaneously
const closeShift = computeLeverageCloseShift(pool, rateFn, {
withdrawAmount: 100_000,
repayAmount: 80_000,
})6. Conversion Utilities
import {
aprToApy,
apyToApr, // APR <-> APY (per-second compounding)
aprPercentToApyPercent, // percentage variants
rayToAprPercent,
aaveRayApyToAprPercent, // Aave RAY-scaled values
perBlockRateToAprPercent, // Compound V2 per-block rates
perSecondRateToAprPercent, // Compound V3 per-second rates
continuousRateToAprPercent, // Morpho continuous rates
depositRateFromBorrowRate, // derive deposit rate
getBlockTime,
setBlockTime, // chain block times
} from '@1delta/irm-sdk'Supported Protocols
| Protocol | Fetcher | Model | Static Data | Static Fetcher |
| --------------------- | -------------------- | ------------- | ---------------------- | ----------------------- |
| Aave V2/V3 | fetchAaveIRM | aave | AaveStaticData | fetchAaveStatic |
| Compound V2 (+ forks) | fetchCompoundV2IRM | compound-v2 | CompoundV2StaticData | fetchCompoundV2Static |
| Compound V3 (Comet) | fetchCompoundV3IRM | compound-v3 | CompoundV3StaticData | fetchCompoundV3Static |
| Morpho Blue | fetchMorphoIRM | morpho | MorphoStaticData | fetchMorphoStatic |
| Euler V2 | fetchEulerIRM | euler | EulerStaticData | fetchEulerStatic |
