# @1delta/margin-fetcher

Multi-protocol lending data fetcher supporting Morpho Blue, Aave V2/V3, Compound V2/V3, Euler, Init, and Lista DAO. 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:

1. **Loan entry** — the borrowable asset
   - Has `borrowingEnabled: true`, `collateralActive: false`
   - Contains deposit/borrow APRs, total supply/debt, liquidity
   - Rewards (MORPHO token incentives) attached here

2. **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)

### 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 result
```

**Morpho 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 values
```

## File 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, MorphoGeneralPublicResponse
```

## Lista 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`](src/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 by `posId`.
- **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 pay `msg.sender`. The `receiver`-variant the composer must use
> (Moolah requires `receiver == broker` when 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.**
- **`onBehalf`** is taken straight from calldata (exactly like Morpho's repay `onBehalfOf`) — repaying
  only ever pays debt down and refunds excess to the composer, so **repay-on-behalf is permissionless**.
  Must be non-zero (broker reverts `ZeroAddress`); there is no caller fallback.
- **`assets == 0`** repays the composer's full balance (`balanceOf` / `selfbalance`); the broker refunds
  any excess. The clean "close it out" pattern: over-fund slightly, repay with `assets == 0`, take the dust.
- **Native:** both repay overloads are `payable` and wrap `msg.value` internally (gated on
  `LOAN_TOKEN == WBNB`, else `NativeNotSupported`). 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 `posId` **reverts** (`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 the `debtStable` total but not yet surfaced as its own repayable term.
