Tutorials

Borrow at a fixed rate

Wallet setup

Define the wallet client and the borrower'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 borrower (taker of bids), 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,
      "loan_token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "maturity":   1798761600,                                  // January 1, 2027
      "collaterals": [
        {          
          "token":   "0x4200...0006",                            // WETH
          "lltv":    "770000000000000000",                       // 77% LTV
          "liquidation_cursor": "250000000000000000",            // 0.25
          "oracle":  "0xOracle..."
        }
      ],

      "asks": [ /* … lending side, ignore for borrowing */ ],

      // ┌──────────────────────────────────────────────────────────────┐
      // │  FOR BORROWING → read the BIDS                               │
      // │  Bids = lenders buying units = offering to lend you money    │
      // │  You (the borrower) take these bids = sell your units        │
      // │  The bid price is what you receive per unit → your cost      │
      // └──────────────────────────────────────────────────────────────┘
      "bids": [
        {
          "tick":   4988,
          "price":  "956937000000000000",                        // ≈ 0.9569 per unit
          "units":  "8000000000",
          "assets": "7655497000",                                // ≈ 7,655 USDC on the bid
          "count":  2
        },
        {
          "tick":   4976,
          "price":  "951000000000000000",                        // ≈ 0.9510 (worse for borrower)
          "units":  "12000000000",
          "assets": "11412000000",
          "count":  4
        }
      ]
    }
  ]
}

Select market & estimate borrow rate

The best bid is the highest price. Higher means you receive more per unit sold, so lower borrowing cost.

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

// Obtain best bid price (highest = cheapest to borrow)
const bestBidPrice = Number(book.bids[0].price) / 1e18;          // ≈ 0.9569

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

// Calculate fixed borrow APR
const borrowAPR = (1 / bestBidPrice - 1) * (365 * 86400) / ttm;  

console.log(`Borrow APR: ${(borrowAPR * 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 borrow from a market with slippage. Morpho's router returns executable takeable offers (quotes against the bids) with fallback liquidity. The total may exceed your target so the bundle can skip stale offers and still fill.

GET https://api.morpho.org/v0/midnight/books/{marketId}/bids/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":  "956937000000000000",  // ≈ 0.9569
    "average_worst_price": "952152000000000000",  // with 0.5% slippage
    "available_assets":    "14000000000",         // 14,000 USDC total bid depth
    "available_units":     "14630000000",

    "takeable_offers": [
      {
        "market_id":     "0xd92de5e7fbb...7614",
        "units":         "8000000000",           // cap: up to 8,000 units from this offer
        "ratifier_data": "0x7c1e...d402",
        "offer": {
          "buy":      true,                      // buy offer (bid) (maker is lender)
          "tick":     4988,
          "maker":    "0xDave...",
          "ratifier": "0xEcrecoverRatifier...",
          // … remaining offer fields
        }
      },
      {
        "market_id": "0xd92de5e7fbb...7614",
        "units":     "6630000000",               // cap: 6,630 units — fallback
        "ratifier_data": "0xab12...e905",
        "offer": { "buy": true, "tick": 4976, "maker": "0xEve...", /* … */ }
      }
    ]
  }
}

// Interpreting the quote
const quote = response.data;
const avgPrice = Number(quote.average_best_price) / 1e18; // 0.9569
const borrowAPR = (1 / avgPrice - 1) * (365 * 86400) / ttm;

// To borrow 10,000 USDC at price ≈ 0.9569 you sell ≈ 10,450 units
// At maturity you repay 10,450 USDC → net cost ≈ 450 USDC
const unitsToSell = Math.ceil(10000e6 / avgPrice);

Step 2: Convert takeable offers to ABI-encoded data

The quote 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),
  };
}

const takeableOffers = quote.takeable_offers;

// Map API offers → 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 browsing books in Step 1

// 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:     "bids",                        // borrower takes bids
  assets:   parseUnits("10000", 6),        // 10,000 USDC to borrow
  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;

// Compute your borrowing cost
const avgPrice = Number(quote.data.averageBestPrice) / 1e18;
const borrowAPR = (1 / avgPrice - 1) * (365 * 86400) / ttm;

Execute the borrow

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

The taker must:

  1. Authorize MidnightBundles on the Midnight contract.
  2. Approve MidnightBundles to pull the collateral token (e.g. WETH).
  3. Hold sufficient collateral to cover the position at the market's LLTV.

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 WETH             = "0x4200000000000000000000000000000000000006";   // WETH on Base
const MIDNIGHT         = "0x...";                                        // Midnight contract

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

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

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

// Path 2: EIP-2612
// For a permit-capable COLLATERAL token only (WETH has no permit() but example assumes COLLATERAL of choise has permit())
const COLLATERAL = "0x...";                                               // a permit-capable collateral token
const collateralAmount = parseUnits("1", 18);                             // 1 unit of collateral 
const publicClient = createPublicClient({ chain: base, transport: http() });

const deadline = BigInt(Math.floor(Date.now() / 1000) + 3600);

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

const sig = await walletClient.signTypedData({
  account: taker,
  // TODO: Replace token name and version based on the COLLATERAL choice
  domain: { name: "<Token Name>", version: "<v>", chainId: 8453, verifyingContract: COLLATERAL },
  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: collateralAmount, nonce, deadline },
});

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

// Variable to set the loanTokenPermit variable to (bundler argument) in the Execute step that follows 
const collateralPermitERC2612 = {
  kind: 1,                                                                  // 1 = ERC2612
  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 collateral asset (i.e. WETH)
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";               

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

// Per-borrow: 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: WETH, amount: collateralAmount },
    spender: MIDNIGHT_BUNDLES,    
    nonce,
    deadline,
  },
});

// Variable to set the loanTokenPermit variable to (bundler argument) in the Execute step that follows 
const collateralPermit2 = {
  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],
});

Execute

Specify the target amount of assets you want to sell (i.e. borrow), a price guard, the collateral you want to post, 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 WETH = "0x4200000000000000000000000000000000000006";                    // WETH contract address

// Borrower sells units to receive USDC, depositing WETH as collateral
const targetSellerAssets = parseUnits("10000", 6);                            // want 10,000 USDC

const worstAcceptablePrice = 0.95;                                            // worst price per unit you'll accept

// Calculate maxUnits to set your own price guard (cap your debt obligation by specifying the maximum number of units you're willing to sell) from worst acceptable borrow rate
// If price drops below 0.95, you'd need to sell more units than this so the bundle reverts instead of giving you a worse rate
const maxUnits = BigInt(Math.ceil(10_000e6 / worstAcceptablePrice));

// Calculate collateral needed for the debt you want to take using the market's LLTV, and the oracle price of the collateral asset:
// 1. Retrieve the collateral's LLTV from the "Browse markets" step: collaterals[0].lltv = "770000000000000000" → 77% (WETH's LLTV for this market)
// 2. Fetch your desireable debt at maturity (maxUnits ≈ 10,527 USDC)
// 3. Estimate the minimum collateral value needed for this configuration (debt / LLTV = 10,527 / 0.77 ≈ 13,671 USDC)
// 4. At WETH ≈ $3,000, minimum debt for this position is ≈ 4.56 WETH (at the liquidation boundary)
// 5. Add a safety buffer (i.e. ~10% headroom above minimum) on that to get to 5 WETH
const collateralAmount   = parseUnits("5", 18);                               // 5 WETH collateral

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

// Collateral is referenced by index into the market's collateralParams[] NOT by token address.
// Here collateralIndex 0 = collateralParams[0] = WETH.
// See Authorizations section for different permit inputs (i.e. collateralPermitNone, collateralPermitERC2612, collateralPermit2)
// The permit is per collateral asset and per-supply action; { kind: 0 } means "already approved on the bundle".
const collateralSupplies = [
  { collateralIndex: 0n, assets: collateralAmount, permit: collateralPermitNone },
];

const hash = await walletClient.writeContract({
  account: taker,
  address: MIDNIGHT_BUNDLES,
  abi: midnightBundlesAbi,
  functionName: "midnightBundlesV1SupplyCollateralAndSellWithAssetsTarget",
  args: [
    targetSellerAssets,  // USDC to receive
    maxUnits,            // revert if more units sold than this (rate protection)
    taker,               // taker — the borrower's address
    false,               // reduceOnly (false for a new borrow position)
    taker,               // receiver (where the borrowed USDC is sent)
    collateralSupplies,  // CollateralSupply[] — WETH to deposit as collateral
    takes,               // takes (takeable offers from the quote)
    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("Borrow tx:", hash);

On this page