@1delta/margin-fetcher
v0.0.323
Published
Multi-protocol lending data fetcher supporting Morpho Blue, Aave V2/V3, Compound V2/V3, Euler, Init, Lista DAO, and Morpho Midnight. Provides public market data (rates, TVL, configs) and per-user position data (balances, shares, collateral) in a unified f
Readme
@1delta/margin-fetcher
Multi-protocol lending data fetcher supporting Morpho Blue, Aave V2/V3, Compound V2/V3, Euler, Init, Lista DAO, and Morpho Midnight. Provides public market data (rates, TVL, configs) and per-user position data (balances, shares, collateral) in a unified format.
How Morpho Blue Works
Protocol overview
Morpho Blue is an isolated-market lending protocol. Unlike pooled protocols (Aave, Compound) where all assets share a single pool, each Morpho market is a standalone pair defined by five parameters:
| Parameter | Description |
|-----------|-------------|
| loanToken | The asset that can be supplied and borrowed |
| collateralToken | The asset deposited as collateral |
| oracle | Price oracle for the pair |
| irm | Interest Rate Model contract |
| lltv | Liquidation Loan-To-Value ratio (18-decimal WAD) |
These five parameters are hashed into a uniqueKey (bytes32) that identifies the market on-chain.
Interest rate model
Morpho uses an adaptive curve IRM that adjusts rates based on utilization relative to a 90% target:
utilization = totalBorrowAssets / totalSupplyAssets
If utilization > 90%: rateAtTarget increases over time (via exponential adjustment)
If utilization < 90%: rateAtTarget decreases over time
borrowRate = curve(rateAtTarget, utilization)
supplyRate = borrowRate * utilization * (1 - fee)The curve uses a steepness factor of 4x, meaning at full utilization the rate is 4x the rate-at-target. Rates are stored as per-second WAD values and compounded continuously.
Key constants:
- Target utilization: 90%
- Curve steepness: 4x
- Min rate at target: ~0.1% APY
- Max rate at target: ~200% APY
- Adjustment speed: 50x/year (exponential)
Share-based accounting
Positions are tracked via shares rather than raw assets. This allows interest to accrue without storage updates per-user:
assets = shares * (totalAssets + 1) / (totalShares + VIRTUAL_SHARES)Where VIRTUAL_SHARES = 1e6 prevents share inflation attacks.
Market data representation
Each Morpho market produces two entries in the normalized output:
Loan entry — the borrowable asset
- Has
borrowingEnabled: true,collateralActive: false - Contains deposit/borrow APRs, total supply/debt, liquidity
- Rewards (MORPHO token incentives) attached here
- Has
Collateral entry — the collateral asset
- Has
borrowingEnabled: false,collateralActive: true - LTV factors derived from the market's
lltv - No interest rates (collateral doesn't earn yield in Morpho)
- Has
Whitelisted vs unlisted markets
Morpho markets can be whitelisted (curated, visible in the Morpho UI) or unlisted (removed from curation). The includeUnlistedMorphoMarkets flag controls whether unlisted markets are fetched from the API. Unlisted markets are tagged with isListed: false on the MorphoMarket params.
Architecture
Data fetching strategy
The fetcher uses a hybrid approach that routes each lender to either an API or on-chain path:
getLenderPublicDataAll(chainId, lenders, ...)
├── lenderCanUseApi(lender, chainId)?
│ ├── YES → getLenderPublicDataViaApi() → Morpho GraphQL API
│ └── NO → getLenderPublicData() → On-chain multicall via Morpho Lens
└── Promise.all([onChain, api]) → merged resultMorpho Blue uses the API on most chains. On-chain fallback is used for chains without API support (OP Mainnet, Soneium, Hemi, Berachain, Sei).
API path (GraphQL)
Fetches from https://blue-api.morpho.org/graphql:
fetchMorphoMarkets(chainId, includeUnlisted)
→ GraphQL query (paginated: 200/page, 2 pages for Ethereum mainnet)
→ GetMarketsResponse
→ convertMarketsToMorphoResponse()
→ { [marketId]: MorphoGeneralPublicResponse }The API provides pre-computed APYs, USD values, and reward data. APY→APR conversion is applied during normalization.
On-chain path (Morpho Lens)
Uses a custom lens contract that returns compact binary-encoded market data:
buildMorphoCall(chainId)
→ multicall to MORPHO_LENS.getMarketDataCompact(morpho, marketHashes[])
→ chunks of 50 markets per call (calldata size limit)
→ raw bytes response
getMorphoMarketDataConverter()
→ decodeMarkets(bytes) // 256-byte records → Market[]
→ MathLib.getBorrowApy(...) // compute rates from rateAtTarget + utilization
→ MathLib.getSupplyApy(...)
→ { [marketId]: MorphoGeneralPublicResponse }Binary encoding (256 bytes per market)
Offset Size Field
0 20 loanToken (address)
20 20 collateralToken (address)
40 20 oracle (address)
60 20 irm (address)
80 16 lltv (uint128)
96 32 price (uint256) — collateral/loan exchange rate in WAD
128 32 rateAtTarget (uint256) — per-second interest rate
160 16 totalSupplyAssets (uint128)
176 16 totalSupplyShares (uint128)
192 16 totalBorrowAssets (uint128)
208 16 totalBorrowShares (uint128)
224 16 lastUpdate (uint128) — timestamp
240 16 fee (uint128)User data path
buildMorphoUserCallWithLens(chainId, account, lender, marketIds)
→ MORPHO_LENS.getUserDataCompact(marketHashes[], account, morpho)
→ chunks of 100 markets per call
decodePackedMorphoUserDataset(hex)
→ [uint16 count] + [130-byte records × count]
→ BalanceInfo[] { index, supplyShares, borrowShares, supplyAssets, borrowAssets, collateral }
getMorphoUserDataConverterWithLens()
→ map balances to market IDs via index
→ separate loan positions (supply/borrow) from collateral positions
→ format to UserData with USD valuesFile structure
src/lending/
├── public-data/
│ ├── fetchLenderAll.ts # Hybrid router (API vs on-chain)
│ ├── fetchLender.ts # On-chain multicall dispatcher
│ ├── fetchLenderExt.ts # API dispatcher
│ └── morpho/
│ ├── fetchPublic.ts # GraphQL query + fetch logic
│ ├── convertPublic.ts # API response → normalized format
│ ├── publicCallBuild.ts # Morpho Lens call builder
│ ├── getMarketsFromChain.ts # On-chain response → normalized format
│ └── utils/
│ ├── evmParser.ts # Binary decoder (256-byte records)
│ ├── mathLib.ts # WAD math, rate calculations
│ └── parsers.ts # LTV parsing, number formatting
├── user-data/
│ └── morpho/
│ ├── userCallBuild.ts # User position call builder
│ ├── decoder.ts # User data binary decoder
│ ├── userCallParse.ts # Balance → UserData conversion
│ ├── morphoLib.ts # Share ↔ asset conversion
│ └── types.ts # Parameter position enums
└── ...
src/types/lender/
└── morpho-types.ts # GetMarketsResponse, MorphoMarket, MorphoGeneralPublicResponseLista DAO extension
Lista is a Morpho Blue fork with additional per-market fields. Uses a 357-byte binary record instead of 256:
| Extra field | Type | Description |
|-------------|------|-------------|
| minLoan | uint128 | Minimum loan amount |
| hasWhitelist | bool | Whether the market has access control |
| loanProvider | address | Yield source for loan token |
| collateralProvider | address | Yield source for collateral |
| broker | address | Authorized broker contract |
User data also includes per-market whitelist flags prepended before the balance records.
Fixed-term broker (LendingBroker)
Some Lista markets register a LendingBroker — the mandatory debt-side gateway of a Moolah
(Morpho-fork) market. Collateral supply/withdraw still go through the normal Moolah path, but
borrow and repay run through the broker, which overlays a richer debt model on top of the raw
Moolah position. The on-chain broker is an ERC1967 proxy (e.g. BNB 0x1Fa2…8b54 → impl 0xb680…58bb),
verified source LendingBroker.sol / BrokerMath.sol. Fetcher support lives in
lending/public-data/lista/listaBroker.ts; the composer
encoders live in contracts-delegation (ListaBrokerLending.sol, CalldataLib.encodeListaBroker*).
Two debt buckets per user. A brokered market has no flexible/variable borrowing at the Moolah layer — instead each user holds:
- N fixed-term positions — each a
{posId, principal, apr, start, end, …}tranche with a locked APR and a maturity. Identified byposId. - One dynamic position — a variable-rate position, the catch-all bucket. Targeted on repay by the
sentinel
posId == type(uint128).max(LISTA_BROKER_DYNAMIC_POS).
broker.getUserTotalDebt(user) is the authoritative total (fixed + dynamic + all interest).
Borrow
The composer calls borrow(amount, termId, user, receiver). user (the position owner) is always
the authenticated caller and must have authorized the composer in Moolah (setAuthorization) — debt
can never be opened against someone else. The broker borrows from Moolah and forwards the loan token to
receiver.
Native: the broker can unwrap WBNB→BNB, but only on its EOA-direct
borrow(amount)/borrow(amount, termId)overloads that paymsg.sender. Thereceiver-variant the composer must use (Moolah requiresreceiver == brokerwhen a broker is registered) always sends ERC20 WBNB, never native. To deliver native BNB, chain an unwrap step after the borrow.
Repay
encodeListaBrokerRepay(loanToken, assets, native, broker, posId, onBehalf) →
repay(amount, posId, onBehalf) (fixed) or repay(amount, onBehalf) (dynamic, sentinel posId).
- Interest-first, then principal.
onBehalfis taken straight from calldata (exactly like Morpho's repayonBehalfOf) — repaying only ever pays debt down and refunds excess to the composer, so repay-on-behalf is permissionless. Must be non-zero (broker revertsZeroAddress); there is no caller fallback.assets == 0repays the composer's full balance (balanceOf/selfbalance); the broker refunds any excess. The clean "close it out" pattern: over-fund slightly, repay withassets == 0, take the dust.- Native: both repay overloads are
payableand wrapmsg.valueinternally (gated onLOAN_TOKEN == WBNB, elseNativeNotSupported). The composer's native path forwards the value and skips the ERC20 approve. Excess refunds as native BNB to the composer (un-swept on explicit amounts).
Early-repayment penalty
Repaying a fixed position's principal before end incurs a penalty
(BrokerMath.getPenaltyForFixedPosition):
penalty = ceil( ceil(repayPrincipal × aprPerSecond / RATE_SCALE) × timeLeft / 2 )≈ half the interest that principal would still accrue over the remaining term. It's skimmed off the
principal payment and supplied to the Moolah vault as revenue, so it is additive — on top of
outstanding + accruedInterest. Penalty is 0 once matured (block.timestamp > end). The fetcher
surfaces it per loan as earlyRepayPenalty (computed for the full outstanding principal; scale linearly
for partials). Use the broker's previewRepayFixedLoanPosition(user, amount, posId) for the exact
(interest, penalty, principal) split.
Maturity & refinancing
A fixed position's interest freezes at end (getAccruedInterestForFixedPosition caps at
position.end); after maturity it accrues no further fixed interest and carries no penalty.
refinanceMaturedFixedPositions(user, posIds) is a bot-only function (onlyRole(BOT)) — users and
the composer cannot call it, so it is not exposed as an SDK/composer op. It migrates matured fixed
positions into the dynamic bucket: net principal + frozen interest are normalized at the current dynamic
rate and added to the dynamic position, and the fixed position is deleted. The window between maturity
and the bot's call is interest-free (the protocol absorbs it).
Repaying late
| When | Target | Cost |
|------|--------|------|
| Before end | fixed posId | principal + accrued interest + early penalty |
| After end, before bot refinances | fixed posId | principal + frozen interest, no penalty — cheapest |
| After bot refinances | dynamic sentinel | principal + interest captured at refinance + dynamic-rate interest since |
Refinance is a race. A repay targeting a matured
posIdreverts (position not found) if the bot refinances first; fall back to the dynamic repay. The matured-but-not-yet-refinanced window (isMatured: true,earlyRepayPenalty: '0') is the cheapest time to repay.
Data shape
Brokered markets emit no variable debt — the position's debt is 0, the authoritative total sits in
debtStable, and each fixed loan is itemized in a terms[] array (ListaTermLoan):
| Field | Description |
|-------|-------------|
| loanId | posId — the repay target for the LISTA_BROKER_REPAY composer op |
| debt | outstanding (principal + accrued interest), loan-token units |
| aprFraction | locked fixed APR as a fraction |
| maturity / termDays | unix maturity timestamp / term length |
| accruedInterest | outstanding accrued interest |
| earlyRepayPenalty | penalty to close the loan now; 0 once matured |
| isMatured | now >= end |
Note:
terms[]currently itemizes only fixed loans. The dynamic position (post-refinance) is included in thedebtStabletotal but not yet surfaced as its own repayable term.
Morpho Midnight extension
Morpho Midnight is a Base-only, fixed-rate / fixed-maturity lending protocol built on an
order book of off-chain signed offers (zero-coupon credit/debt units) — it is NOT a Morpho
Blue fork, so it is modeled as its own 'midnight' provider (Lender.MORPHO_MIDNIGHT,
isMidnight), not isMorphoType. There is no pool and no utilization curve: rates are derived
from each offer's tick → price and its time-to-maturity, knowable only via the API. Each
maturity is its own isolated market, keyed MORPHO_MIDNIGHT_<marketId>.
API-first fetching
Unlike Morpho Blue's GraphQL-then-on-chain hybrid, Midnight public data is API-only — there
is no on-chain public fallback in fetchLenderAll. The fetcher lives in
lending/public-data/midnight/:
apiClient/fetchPublic— anApiBookSourcereads the order book per market (GET /books/{id}) from the Midnight API. The base URL resolves override → config → hosted default (https://api.morpho.org/v0/midnight); the worker can point it at a dev API viaMIDNIGHT_API_BASE(seedata-sdksetMidnightApiBase).math.ts— portedTickLib(tickToApretc.), bit-verified against@morpho-org/midnight-sdk.convertPublic— normalizes the book into aMorphoGeneralPublicResponsekeyed byMORPHO_MIDNIGHT_<id>, emitting the fixed borrow/supply APR and a single-termparams.market.terms[]({termId, durationSecs, durationDays, apr}) carrying the maturity — so the maturity flows through the same genericterms[]path as Lista's fixed loans. Units are in the loan token's decimals (not 18):assets = units × price / WAD, sounits ≈ assets.
User positions ({credit, debt, collateral[128], collateralBitmap}) are read via multicall in
lending/user-data/midnight/, one UserData per market.
Scope: the fetcher and direct spot actions (supply collateral / borrow-via-
take/ repay / withdraw) are wired. Composer-routed leverage and native ETH are deferred — seecalldata-sdk/src/evm/generic/midnight/COMPOSER_PLAN.md.
