Tutorials

Lend at a fixed rate

Wallet setup

Define the wallet client and the lender's address. This is the account that will sign transactions and receive units at maturity.

import {
  createWalletClient,
  http,
  parseUnits,
  zeroAddress,
  type Address,
} from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

// The lender (taker of asks), i.e. your wallet address
const taker: Address = "0xYourWalletAddress";

// Create a wallet client connected to Base
// This example uses a simple EOA with a private key for brevity 
// You can substitute in any wallet solution that fits your setup
const walletClient = createWalletClient({
  account: privateKeyToAccount("0xYourPrivateKey"),  
  chain: base,
  transport: http(),
});

Browse markets

Fetch the list of books filtered by chain and loan token. Each book represents one market with its top ask/bid price levels:

GET https://api.morpho.org/v0/midnight/books
  ?chain_ids=8453                                         // Base
  &loan_tokens=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 // USDC on Base
  &sort=maturity

// Response (each book is one market with its top ask/bid levels)
{
  "cursor": "eyJzb3J0Ijpb...",                            // for pagination (null when no more pages)
  "data": [
    {
      "market_id": "0xd92de5e7fbb...7614",                // this is the ID you will use in the next step
      "chain_id":   8453,
      "maturity":   1798761600,                           // Dec 31, 2026
      "loan_token": "0xa0b8...3c02",                      
      "collaterals": [
        {
          "token":   "0x4200...0006",                     // WETH
          "lltv":    "770000000000000000",                // 77% LTV
          "liquidation_cursor": "250000000000000000",     // 0.25
          "oracle":  "0xOracle..."
        }
      ],

      // ┌─────────────────────────────────────────────────────────┐
      // │  FOR LENDING → read the ASKS                            │
      // │  Asks = makers selling units = borrowers seeking loans  │
      // │  You (the lender) take these asks = buy their units     │
      // │  The ask price is what you pay per unit → your rate     │
      // └─────────────────────────────────────────────────────────┘
      "asks": [
        { "tick": 42, "price": "952380...", "units": "10000000000", "assets": "9523809523", "count": 3 },
        { "tick": 43, "price": "948000...", "units": "18500000000", "assets": "17538000000", "count": 5 }
      ],

      // (bids = lenders' buy-offers, you'd read these for BORROWING)
      "bids": [
        { "tick": 41, "price": "956937...", "units": "8000000000", "assets": "7655497000", "count": 2 }
      ]
    },
    // … more markets at different maturities
  ]
}

Select market & estimate lend rate

The best ask is the cheapest unit price. Convert the price into an annualized rate:

// Select book data
const book = response.data[0];

// Obtain best ask price (scaled by 1e18)
const bestAskPrice = Number(book.asks[0].price) / 1e18;  // ≈ 0.9524

// Calculate time to maturity (ttm) in seconds
const ttm = book.maturity - Date.now() / 1000;

// Calculate fixed lend APR
// Note: This assumes the entire lend size fits within that first tick's available units. 
// If lend size is higher than the assets at that tick, the effective blended rate would be lower. 
// The Morpho router (next step) returns the accurate blended price stemming from all takeable offers available.
const lendAPR = (1 / bestAskPrice - 1) * (365 * 86400) / ttm; // ≈ 5.2%

console.log(`Lend APR: ${(lendAPR * 100).toFixed(2)}%`);

// If the rate looks good, save the market ID for the next step
const marketId = book.market_id;   // "0xd92de5e7fbb...7614"

Get a quote for your offer

API

Step 1: Get a quote

Specify the amount of USDC you want to lend to a market with slippage. The Morpho router (via the quote API shown below) returns executable takeable offers with fallback liquidity (i.e. additional offers returned in case offers are partially consumed). This way, during execution, the bundle can skip stale offers and still fill the lender's request.

GET https://api.morpho.org/v0/midnight/books/{marketId}/asks/quote
  ?assets=10000000000  // 10,000 USDC (6 decimals)
  &slippage=0.5       // 0.5% slippage guard

// Response: the router's execution plan:
{
  "data": {
    "average_best_price":  "947619047619047619",  // best avg price (≈ 0.9524)
    "average_worst_price": "952380952380952380",  // with 0.5% slippage guard
    "available_assets":    "15000000000",         // 15,000 USDC total available
    "available_units":     "15750000000",         // units you'd receive

   // This is what you pass to the bundle during execution
    "takeable_offers": [
      {
        "market_id":     "0xd92de5e7fbb...7614",
        "units":         "5000000000",           // max 5,000 units from this offer
        "ratifier_data": "0x4a8b...f201",        // ECDSA signature from the maker
        "offer": {                               // full Offer struct (snake_case)
          "buy":      false,                     // sell offer (ask) — maker is borrowing
          "tick":     42,                        // price tick → 0.9524
          "maker":    "0xAlice...",
          "ratifier": "0xEcdsaRatifier
          // A takeable offer with a "group" can't be treated as guaranteed liquidity for the full "max_assets" it returns.
          // Between the time Morpho's router indexed it and the time you submit your transaction onchain the group's consumed counter may have increased from fills on sibling offers.
          // Your take would either partially fill (up to whatever's left) or revert if nothing remains. This is why the router returns additional offers as part of the takeable_offers array.
          "group":    "0xabc...",
          "max_units": "5250000000",
          "max_assets": "5000000000",
          "start":    1718700000,
          "expiry":   1719304800,
          "reduce_only": false,
          "receiver_if_maker_is_seller": "0xAlice...",
          // … market{}, callback, callback_data
        }
      },
      {
        "market_id":     "0xd92de5e7fbb...7614",
        "units":         "7000000000",           // 7,000 units — next best price
        "ratifier_data": "0x91cd...a803",
        "offer": {
          "buy": false,
          "tick": 43,                            // slightly worse tick
          "maker": "0xBob...",
          // …
        }
      },
      {
        "market_id":     "0xd92de5e7fbb...7614",
        "units":         "3750000000",           // 3,750 units — fallback excess
        "ratifier_data": "0xfe20...b112",
        "offer": {
          "buy": false,
          "tick": 43,
          "maker": "0xCharlie...",
          // …
        }
      }
    ]
    // Total: 15,750 units across 3 offers (your target: ~10,500)
    // The extra ~5,250 units are fallback in case offers are partially consumed
  }
}

// Interpreting the quote
const quote = response.data;
const avgPrice = Number(quote.average_best_price) / 1e18;      // ≈ 0.9524
const impliedRate = (1 / avgPrice - 1) * (365 * 86400) / ttm;  // ≈ 5.2% APR
const targetUnits = BigInt(quote.available_units);             // receive 15,750 units at maturity equivalent to 10.5k USDC

Step 2: Convert takeable offers to ABI-encoded data

The API returns snake_case JSON, whereas the onchain ABI expects camelCase tuples. The apiOfferToAbi helper converts the API offer to an ABI struct.

function apiOfferToAbi(apiOffer: any) {
  return {
    market: {
      chainId:        BigInt(apiOffer.market.chain_id),
      midnight:       apiOffer.market.midnight,
      loanToken:      apiOffer.market.loan_token,
      collateralParams: apiOffer.market.collaterals.map((c: any) => ({
        token:  c.token,
        lltv:   BigInt(c.lltv),
        liquidationCursor: BigInt(c.liquidation_cursor),
        oracle: c.oracle,
      })),
      maturity:       BigInt(apiOffer.market.maturity),
      rcfThreshold:   BigInt(apiOffer.market.rcf_threshold),
      enterGate:      apiOffer.market.enter_gate,
      liquidatorGate: apiOffer.market.liquidator_gate,
    },
    buy:                       apiOffer.buy,
    maker:                     apiOffer.maker,
    maxUnits:                  BigInt(apiOffer.max_units),
    maxAssets:                 BigInt(apiOffer.max_assets),
    start:                     BigInt(apiOffer.start),
    expiry:                    BigInt(apiOffer.expiry),
    tick:                      BigInt(apiOffer.tick),
    group:                     apiOffer.group,
    callback:                  apiOffer.callback,
    callbackData:              apiOffer.callback_data,
    receiverIfMakerIsSeller:   apiOffer.receiver_if_maker_is_seller,
    ratifier:                  apiOffer.ratifier,
    reduceOnly:                apiOffer.reduce_only,
    continuousFeeCap:          BigInt(apiOffer.continuous_fee_cap),
  };
}

// Takeable offers from quote API
const takeableOffers = quote.takeable_offers;

// Map offers to ABI-ready structs via helper
const takes = takeableOffers.map((t) => ({
  offer: apiOfferToAbi(t.offer),
  ratifierData: t.ratifier_data,
  units: BigInt(t.units),
}));

SDK

MidnightApi.fetchBookQuote wraps the quote endpoint as part of the midnight-sdk and returns ABI-ready take objects

Step 1: Install dependencies

npm install @morpho-org/midnight-sdk viem

Step 2: Get ABI-ready takeable offers

import { MidnightApi, type MidnightApiTake } from "@morpho-org/midnight-sdk/api";
import { parseUnits } from "viem";

const marketId = "0xd92de5e7fbb...7614";   // from the "Browse markets" step

// SDK wraps the quote endpoint and maps the response into ABI-ready take objects (uses Morpho's router by default, no config needed)
const quote = await MidnightApi.fetchBookQuote({
  marketId,
  side:     "asks",                        // lender takes asks (not bids)
  assets:   parseUnits("10000", 6),        // 10,000 USDC to lend
  slippage: "0.5",                         // 0.5% slippage guard
});

// quote.data contains:
//   .averageBestPrice   (BigInt)
//   .averageWorstPrice  (BigInt)
//   .availableAssets    (BigInt)
//   .availableUnits     (BigInt)
//   .takeableOffers     (MidnightApiTake[], ABI-ready)

// ABI-ready takes
const takes: MidnightApiTake[] = quote.data.takeableOffers;

Execute the lend

The recommended path for quotes spanning multiple offers is the MidnightBundles contract. The bundle contract fills toward your target in one transaction, skipping stale offers automatically.

Prerequisites

  1. Approve MidnightBundles contract to pull loan token (i.e. USDC)
  2. Authorize MidnightBundles on the Midnight contract.

Authorizations & Approvals

Pick between Path 1, Path 2, or Path 3 depending on your UX and signing needs.

import {
  erc20Abi, maxUint256, createPublicClient, encodeAbiParameters, parseAbiParameters, parseSignature, parseUnits, http,
} from "viem";
import { base } from "viem/chains";

const MIDNIGHT_BUNDLES = "0x6688dEc8878f43905e11B3C6Bc025E098133144f";   // MidnightBundles contract
const USDC            = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";    // USDC on Base
const MIDNIGHT         = "0x...";                                        // Midnight contract

// Path 1: approve()
// 1. Let MidnightBundles pull USDC during the buy
// 2. maxUint256 = standing allowance
await walletClient.writeContract({
  account: taker,
  address: USDC,
  abi: erc20Abi,
  functionName: "approve",
  args: [MIDNIGHT_BUNDLES, maxUint256],
});

// Variable to set the loanTokenPermit variable to (bundler argument) in the Execute step that follows 
const tokenPermitNone   = { kind: 0, data: "0x" } as const;

// --------------------------------------------------------------

// Path 2: EIP-2612 (Permit)
// Authorize exactly the USDC you'll spend buying units
const permitValue = parseUnits("10000", 6);                              // = targetBuyerAssets (10,000 USDC)
const deadline    = BigInt(Math.floor(Date.now() / 1000) + 3600);        // 1 hour expiry

const publicClient = createPublicClient({ chain: base, transport: http() });

// Read the taker's current EIP-2612 nonce from the USDC contract
const nonce = await publicClient.readContract({
  address: USDC,
  abi: [{ name: "nonces", type: "function", stateMutability: "view",
          inputs: [{ name: "owner", type: "address" }],
          outputs: [{ type: "uint256" }] }],
  functionName: "nonces",
  args: [taker],
});

// Offchain signature (no transaction, no gas)
const sig = await walletClient.signTypedData({
  account: taker,
  domain: { name: "USD Coin", version: "2", chainId: 8453, verifyingContract: USDC },
  types: { Permit: [
    { name: "owner",    type: "address" },
    { name: "spender",  type: "address" },
    { name: "value",    type: "uint256" },
    { name: "nonce",    type: "uint256" },
    { name: "deadline", type: "uint256" },
  ] },
  primaryType: "Permit",
  message: { owner: taker, spender: MIDNIGHT_BUNDLES,
             value: permitValue, nonce, deadline },
});

const { r, s, v } = parseSignature(sig);

// Variable to set the loanTokenPermit variable to (bundler argument) in the Execute step that follows 
const loanTokenPermitERC2612 = {
  kind: 1,                                                             // 1 = ERC-2612
  data: encodeAbiParameters(
    parseAbiParameters("uint256 deadline, uint8 v, bytes32 r, bytes32 s"),
    [deadline, Number(v), r, s],
  ),
} as const;

// --------------------------------------------------------------

// Path 3: Permit2
// One-time setup (once per token, ever): approve Permit2 on USDC
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";          // canonical, same on every chain
const permitValue = parseUnits("10000", 6);                            // = targetBuyerAssets (10,000 USDC)

// One-time setup (once per token, ever): approve Permit2 on USDC. After this, every future authorization is just a signature
await walletClient.writeContract({
  account: taker, address: USDC, abi: erc20Abi,
  functionName: "approve", args: [PERMIT2, maxUint256],
});

// Per-take: sign a Permit2 SignatureTransfer (offchain, no gas).
const nonce    = BigInt(Date.now());                                   // unordered nonce (Permit2 uses a bitmap)
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);

const sig = await walletClient.signTypedData({
  account: taker,
  domain: { name: "Permit2", chainId: 8453, verifyingContract: PERMIT2 },
  types: {
    PermitTransferFrom: [
      { name: "permitted", type: "TokenPermissions" },
      { name: "spender",   type: "address" },
      { name: "nonce",     type: "uint256" },
      { name: "deadline",  type: "uint256" },
    ],
    TokenPermissions: [
      { name: "token",  type: "address" },
      { name: "amount", type: "uint256" },
    ],
  },
  primaryType: "PermitTransferFrom",
  message: {
    permitted: { token: USDC, amount: permitValue },
    spender: MIDNIGHT_BUNDLES,                                           // contract pulls via Permit2
    nonce,
    deadline,
  },
});

// Variable to set the loanTokenPermit variable to (bundler argument) in the Execute step that follows 
const loanTokenPermit2 = {
  kind: 2,                                                               // 2 = Permit2
  data: encodeAbiParameters(
    parseAbiParameters("uint256 nonce, uint256 deadline, bytes signature"),
    [nonce, deadline, sig],
  ),
} as const;

// --------------------------------------------------------------

// Authorize MidnightBundles to act on your position (one-time boolean, persists across future actions)
await walletClient.writeContract({
  account: taker,
  address: MIDNIGHT,
  abi: midnightAbi,
  functionName: "setIsAuthorized",
  args: [MIDNIGHT_BUNDLES, true, taker],
});

Execute

Specify the target amount of assets you want to buy (i.e. lend), a price guard, and the bundler's arguments.

import { createWalletClient, http, parseUnits, zeroAddress, maxUint256 } from "viem";
import { base } from "viem/chains";
import { midnightBundlesAbi } from "@morpho-org/midnight-sdk";

const MIDNIGHT_BUNDLES = "0x6688dEc8878f43905e11B3C6Bc025E098133144f";      // MidnightBundles contract on Base

const targetBuyerAssets = parseUnits("10000", 6);  // 10,000 USDC

// Set your own price guard (caps the minimum units you'll receive, preventing you from getting a worse yield than expected)
// For example, if avg fill price rises above 0.96 → fewer units → bundle reverts
const worstAcceptablePrice = 0.96;
const minUnits = BigInt(Math.ceil(10_000e6 / worstAcceptablePrice));

// Deadline: the transaction reverts if not included before this timestamp
const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now

// The bundle's 5th argument (loanTokenPermit), a TokenPermit { kind, data }, is how you tell the bundle to move your USDC.
// The possible values are outlined in "Authorizations & Approvals" step. 
const hash = await walletClient.writeContract({
  account: taker,
  address: MIDNIGHT_BUNDLES,
  abi: midnightBundlesAbi,
  functionName: "midnightBundlesV1BuyWithAssetsTargetAndWithdrawCollateral",
  args: [
    targetBuyerAssets,   // how much USDC to spend
    minUnits,            // revert if fewer units returned (yield protection)
    taker,               // taker (the lender's address where the bought units are credited to)
    false,               // reduceOnly (false for a new lend position)
    tokenPermitNone,     // loan-token permit (none = pre-approved)
    takes,               // the takeable offers from the quote (ABI-encoded)
    [],                  // collateralWithdrawals (empty for lend)
    taker,               // collateralReceiver (unused — no withdrawals)
    0n,                  // referralFeePct (0 = none)
    zeroAddress,         // referralFeeRecipient (none)
    maxUint256,          // maxContinuousFee (type(uint256).max disables the check)
    deadline             // deadline (reverts if tx is included after this timestamp)
  ],
});

console.log("Lend tx:", hash);

On this page