npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@circle-fin/borrow-kit

v1.0.0

Published

SDK for Circle Borrow Kit operations

Readme

Borrow Kit

License

Borrow Kit is the Circle SDK surface for loan discovery and lifecycle operations, including listing and reading loan positions, repaying loans, closing loans, adding and withdrawing collateral, client-signed loan webhook registration, and reading and claiming rewards for a wallet. It supports both functional calls and the BorrowKit facade.

registerWebhook is a permissionless operation for signing and submitting (or removing) a per-loan callback URL. There is no on-chain transaction and no API key: configuring one for this operation fails locally instead of being ignored. The connected wallet, which must be the loan's owner, signs an EIP-712 WebhookRegistration intent that the Borrow Service verifies off-chain (including EIP-1271 support for smart contract accounts).

Installation

npm install @circle-fin/borrow-kit
# or
yarn add @circle-fin/borrow-kit

Install an adapter for the wallet stack you use. For EVM flows:

npm install @circle-fin/adapter-viem-v2 viem
# or
yarn add @circle-fin/adapter-viem-v2 viem

Quick Start

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const kit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

const { loans } = await kit.getLoans({
  walletAddress: '0x1111111111111111111111111111111111111111',
  chain: 'Arc_Testnet',
})
console.log(`Health factor: ${loans[0]?.healthFactor}`)

const quote = await kit.getClaimRewardsQuote({
  from: { adapter, chain: 'Arc_Testnet' },
})
console.log(`Claimable rewards: ${quote.rewards.length}`)

const result = await kit.claimRewards({
  from: { adapter, chain: 'Arc_Testnet' },
})
if (result.status === 'claimed') {
  console.log(`Claimed ${result.rewards.length} reward(s), tx: ${result.txHash}`)
}

Core Operations

  • getLoans: list a wallet's loans, with pagination.
  • getPosition: fetch a single loan by ID, including collateral, debt, and health fields.
  • borrow: atomically open a loan in a market or borrow more against an existing one.
  • getRepayQuote: read a partial-repayment quote for a loan without executing a transaction.
  • repay: atomically execute a loan repayment, in part or in full.
  • getClaimRewardsQuote: read claimable rewards for a wallet without executing a transaction.
  • claimRewards: claim rewards for a wallet, or return without a transaction when none are claimable.
  • getSupportedChains: list the chains the configured providers serve, for example to populate a chain picker.

getLoans accepts an optional pageSize and returns one page of loans plus pagination.pageAfter — an opaque cursor, not a page number. Pass it back as pageAfter on the next call to fetch the following page; a missing pagination.pageAfter means there are no more loans to fetch.

Configuration

Borrow Kit works without a credential. A Circle API key is optional only on borrow and getBorrowQuote, where it selects and attributes the integrator fee. It is required by getIntegratorConfig and setIntegratorConfig. Every other Borrow Service API is keyless and rejects a configured key instead of silently ignoring or sending it. Pass an accepted key on the provider or per operation via the config: { apiKey } field:

await kit.getBorrowQuote({
  chain: 'Arc_Testnet',
  marketId,
  walletAddress: '0x1111111111111111111111111111111111111111',
  borrowAmount: '1000.0',
  config: {
    apiKey: process.env.CIRCLE_API_KEY,
  },
})

apiKey takes a Circle API key (<ENV>_API_KEY:<keyId>:<keySecret>) — the value the Circle console issues. A legacy KIT_KEY:<keyId>:<keySecret> value goes in the same field and still authenticates; the console no longer issues them. apiKey is the only credential field Borrow Kit accepts. Either way the credential is a server-only secret and is refused in a browser.

Custom providers can be supplied when creating the kit context.

Get a loan position

import { BorrowKit } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()
const loan = await borrowKit.getPosition({ loanId: '11111111-1111-4111-8111-111111111111' })

if (loan.dataStatus === 'PENDING') {
  // Economics not materialized yet — poll until READY.
} else {
  console.log(`Health factor: ${loan.healthFactor}`)
  console.log(`Collateral: ${loan.collateral?.amount} ${loan.collateral?.token}`)
  console.log(`Borrowed: ${loan.borrowed?.amount} ${loan.borrowed?.token}`)
}

dataStatus says whether the loan's economics have converged. A loan whose on-chain create has not been indexed yet — the state a borrow() leaves behind — comes back with dataStatus: 'PENDING', status: 'active', and collateral, borrowed, and healthFactor all null. That is a normal transient state, not an error: poll getPosition until dataStatus turns READY. The same applies to getLoans, where every economic field on a PENDING row is null.

Once READY, healthFactor is still null when the loan carries no debt — that's the "no risk yet" state, not a missing value, and a fully wound-down loan comes back READY with zeroed amounts and status: 'closed'. A loanId that never landed on-chain within its signature window throws a KitError carrying BorrowError.LOAN_NOT_FOUND.

getLoans rows carry the same status discriminator. Prefer it over inferring the lifecycle from amounts: a closed loan and a READY live one that has repaid everything both report '0' collateral and debt. A row whose create has not been indexed yet is a different case — dataStatus: 'PENDING' with null economics, not zeros. status is optional on this route: undefined means the service reported no status, which is not the same as 'active', and getPosition is the fallback that always reports one.

Read a market

import { BorrowKit } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()
const market = await borrowKit.getMarket({
  chain: 'Arc_Testnet',
  marketId: '0x1111111111111111111111111111111111111111111111111111111111111111',
})

getMarket is a keyless read — no wallet signature or credential is required, and a configured credential is rejected. A market the chain does not recognize throws a KitError with code BorrowError.MARKET_NOT_FOUND.

A market's token amounts (liquidity, borrowAssets, and the optional borrowCap) use the same { token, tokenAddress, amount, decimals } shape as every other amount in this kit, with amount as a human-readable decimal string denominated in the market's loanAsset. The ratio fields (lltv, borrowApy, utilization) are plain numbers.

A market's economics are cached projections, not live reads: refreshedAt says how fresh they are, and lltv, borrowAssets, liquidity, borrowApy, utilization, and refreshedAt are all null on a market that has been discovered but not yet backfilled by the cache. That is a normal transient state, not an error — re-fetch later once the cache has caught up. An idle market keeps its last-known values and old refreshedAt rather than reporting zeros, so null unambiguously means "never backfilled." borrowCap is always null (Morpho Blue has no per-market cap).

Discover markets

import { BorrowKit } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()

// One filtered, sorted page:
const { markets, pagination } = await borrowKit.exploreMarkets({
  chain: 'Arc_Testnet',
  sortBy: 'borrowApy',
  minLltv: '0.8',
})

// Or iterate every market, following the cursor for you:
for await (const market of borrowKit.exploreMarketsIterator({
  chain: 'Arc_Testnet',
})) {
  console.log(market.marketId, market.borrowApy ?? 'unrefreshed')
}

exploreMarkets returns one page plus pagination.pageAfter (an opaque cursor; pass it back as pageAfter for the next page). exploreMarketsIterator follows that cursor for you and yields each market. Both are permissionless reads and return the same MarketInfo shape as getMarket above (human-readable token amounts, numeric ratios, and the same possibly-null economics on an unrefreshed market).

Borrow USDC

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const borrowKit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

// Open a loan in a market.
const opened = await borrowKit.borrow({
  from: { adapter, chain: 'Arc_Testnet' },
  marketId: '0x1111111111111111111111111111111111111111111111111111111111111111',
  borrowAmount: '1000.0',
})

// Or borrow more against a loan that already exists.
const grown = await borrowKit.borrow({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  borrowAmount: '500.0',
})

Pass either marketId to open a loan or loanId to grow one — exactly one of the two. A borrow-more takes its market from the loan it names.

The collateral is sized by the Borrow Service, not by the caller: it targets a health factor for the amount requested, and slippageBps bounds how much collateral that sizing may take. Omitting slippageBps applies the service default of 300 bps. Passing 0 is honored as no drift buffer rather than read as unset, so the service sizes collateral exactly — pick it only when you want exact-or-fail behaviour, since a position that moves between the quote and execution can then make the write revert.

borrow submits one atomic batch: an exact collateral approval when the current allowance is short, a Morpho authorization naming the Adapter Contract, the Circle-signed execution, and a matching revocation after it. The grant and the revocation always ride in the same batch, so the Adapter never holds authority over the position between operations. A borrow is refused when the owner already carries a standing grant on Morpho — revoke it by calling setAuthorization against the Morpho Blue contract from the owner wallet, which is a call Borrow Kit does not expose.

The result is a union: status: 'confirmed' carries the amount borrowed and a transaction hash, while status: 'submitted' carries the batch ID and any confirmation error, which is what a smart-account wallet returns when it accepts a batch but cannot report where it landed. Switch on status rather than checking for a txHash.

if (opened.status === 'confirmed') {
  console.log(opened.amountBorrowed.amount, opened.txHash)
} else {
  console.log(opened.batchId)
}

Reuse the same optional idempotencyKey when retrying a request by hand; retry(error) does it for you, whether or not you supplied one.

Preview a borrow

import { BorrowKit } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()
const marketId =
  '0x1111111111111111111111111111111111111111111111111111111111111111'

// What a borrow would cost, and the health it would leave behind.
const quote = await borrowKit.getBorrowQuote({
  chain: 'Arc_Testnet',
  marketId,
  walletAddress: '0x1111111111111111111111111111111111111111',
  borrowAmount: '1000.0',
})

// Preview borrowing more against an existing loan. Its owner, chain, and
// market are resolved from the loan ID.
const borrowMoreQuote = await borrowKit.getBorrowQuote({
  loanId: '11111111-1111-4111-8111-111111111111',
  borrowAmount: '250.0',
})

// The collateral that reaches a chosen health factor.
const sizing = await borrowKit.getRequiredCollateral({
  chain: 'Arc_Testnet',
  marketId,
  borrowAmount: '1000.0',
  targetHealthFactor: 1.5,
})

// The largest borrow a collateral amount supports.
const ceiling = await borrowKit.getMaxBorrow({
  chain: 'Arc_Testnet',
  marketId,
  collateralAmount: '0.05',
})

None of the three takes an adapter and none produces a signature, so they are safe to call while a user is still choosing an amount.

getBorrowQuote returns the collateralAmount the borrow would pull, the loanAssetAmount it would deliver, the market's borrowApy, fees and gasFees, and the health the position would land on: resultingHealthFactor, resultingLtv, resultingBand, and liquidationPrice. getRequiredCollateral and getMaxBorrow are market-scoped rather than loan-scoped, and return a narrower shape: chain, the sized amount (requiredCollateral or maxBorrowAmount), resultingHealthFactor, and liquidationPrice — no fees, gasFees, resultingLtv, or resultingBand, since BorrowKit skims its fee at origination and gas is unmodeled for a preview. Amounts carry the token that denominates them — { token, tokenAddress, amount } — with amount in human-readable decimal.

Every field is computed by the Borrow Service and passed through unchanged. A quote is a snapshot rather than a commitment: the borrow itself prices again at execution, and slippageBps bounds how far the collateral may move.

Follow the steps of a borrow

import { BorrowKit } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()

borrowKit.on('execute', ({ values }) => {
  if (values.state === 'success') console.log('borrowed in', values.txHash)
})

borrowKit.on('*', ({ method, values }) => {
  console.log(method, values.state)
})

A borrow reports four phases: fetchParams for the Borrow Service call that returns the signed execution, approve for the collateral approval, and setAuthorization and execute for the Morpho grant and the Adapter call. The three on-chain phases ride in one atomic batch, so they reach success together and share a transaction hash; a phase that stops at pending was prepared into a batch whose outcome the wallet never reported.

off removes a handler, and needs the same function reference under the same phase. A handler that throws is contained — it cannot fail a borrow that already landed on-chain.

Retry a failed write

import { BorrowKit, isRetryableError } from '@circle-fin/borrow-kit'

const borrowKit = new BorrowKit()

try {
  await borrowKit.repay(params)
} catch (error) {
  if (isRetryableError(error)) {
    const result = await borrowKit.retry(error)
  }
}

retry takes the error a failed borrow, repay, addCollateral, closeLoan, or withdrawCollateralRepayIfNeeded threw and runs that write again. Check isRetryableError(error) first: a fatal failure, such as a rejected parameter, is refused here rather than retried.

Because those writes land as one atomic batch, a failed attempt changed nothing on-chain. When the execution the service signed has not expired, the retry submits it again instead of asking for a new one; otherwise it runs the write from the start.

Either way the retry is safe without a caller-supplied idempotencyKey. The SDK generates one for every write that does not carry its own and records it on the failure, so a retry that has to run the write from the start replays the original request under the original key rather than opening a second, independently keyed one. A write that already confirmed is refused, because running it again would apply the same on-chain change twice.

Supplying your own idempotencyKey is still worth doing when the retry has to outlive the error object — a new process, or a queue — since the generated key lives only on the failure the SDK hands you.

claimRewards, registerWebhook, and setIntegratorConfig are not covered.

Repay a loan

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const borrowKit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

const repayQuote = await borrowKit.getRepayQuote({
  chain: 'Arc_Testnet',
  loanId: '11111111-1111-4111-8111-111111111111',
  repayAmount: '1.0',
})

const repayment = await borrowKit.repay({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  repayAmount: '1.0',
})

repay reads the existing USDC allowance and submits one atomic batch: an approval when required, followed by the Circle-signed Adapter execution. It returns status: 'confirmed' with a transaction hash after confirmation, or status: 'submitted' with a batch ID when the wallet accepted the batch but cannot report its final status. Reuse the same optional idempotencyKey when retrying a request by hand; retry(error) does it for you, whether or not you supplied one. Quote health and gas fields are service-computed and passed through unchanged.

A repayAmount below the outstanding debt is repaid to the atom. One that clears the debt cannot be named that way: interest accrues until the batch lands, so the service denominates it as a share of the position and approves the amount it quoted plus a buffer. The approval is bounded either way — never below the amount you asked for, and never more than 3% above it — and whatever the repayment does not spend is swept back to the wallet in the same transaction.

Register a webhook

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const kit = new BorrowKit()
const privateKey = process.env.PRIVATE_KEY
if (!privateKey) throw new Error('PRIVATE_KEY is required')
const adapter = createViemAdapterFromPrivateKey({ privateKey })

const result = await kit.registerWebhook({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  webhookUrl: 'https://example.com/webhook',
})

console.log(`Registered webhook: ${result.webhookUrl}`)

Unsetting a webhook

Omit webhookUrl (or pass null) to remove a loan's current webhook:

await kit.registerWebhook({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
})

Receiving health-band notifications

A registered webhook receives a POST when the loan's health factor crosses a band boundary. The notification names the loan, the band it entered and the band it left.

The body is a Circle notification envelope. notificationType is morpho.healthBandCrossed, and the crossing is under notification:

{
  "subscriptionId": "5d0f3b2a-8c41-5e7b-b9d2-3a6e1f4c7b80",
  "notificationId": "c3e9a1d4-7f25-4b8e-9d06-2a5f8e1b4c73",
  "notificationType": "morpho.healthBandCrossed",
  "notification": {
    "domain": "26",
    "event_id": "2f1c7a9e-5b3d-5e8f-9a64-0c7d3b1e8f25",
    "loan_id": "11111111-1111-4111-8111-111111111111",
    "previous_threshold": "SAFE",
    "resourceId": "11111111-1111-4111-8111-111111111111",
    "source_block_number": 64491952,
    "threshold": "WARN",
    "walletAddress": "0x1234567890123456789012345678901234567890"
  },
  "timestamp": "2026-09-28T20:00:53.974328260Z",
  "version": 2
}

threshold is the band the loan entered and previous_threshold the band it left: SAFE, WARN, URGENT, IMMINENT or LIQUIDATABLE. walletAddress, domain and resourceId route the delivery: the loan owner, the chain's CCTP domain, and the loan again.

A permissionless receiver cannot yet verify that a notification came from Circle, so treat one as a prompt to check the loan, not as its state. Before acting, call getPosition for the loan it names and act on the healthFactorBand that returns.

Delivery is at-least-once: the same crossing can arrive more than once. Missing an alert is worse than repeating one, so a notification whose delivery cannot be confirmed is sent again, and a crossing the service records a single time may still be published several times.

Deduplicate on the notification's event_id, not the envelope's notificationId, which changes when the service publishes the same crossing again. event_id is stable across every delivery of the same crossing and distinct across separate crossings. Record an event_id only after the notification is confirmed and handled; recording it earlier lets a forged or failed delivery make the real one look like a duplicate. A handler that is not idempotent will count one crossing more than once.

source_block_number is the block the crossing was evaluated at, so it orders two crossings for the same loan even when they do not arrive in that order.

Functional usage (webhooks)

For a functional (non-class) API, use createBorrowKitContext together with the standalone registerWebhook operation:

import {
  createBorrowKitContext,
  registerWebhook,
} from '@circle-fin/borrow-kit'

const context = createBorrowKitContext()

const result = await registerWebhook(context, {
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  webhookUrl: 'https://example.com/webhook',
})

Error Handling

Failures surface as KitError instances from @core/errors. Borrow-specific errors are available under BorrowError:

import { BorrowError, isKitError } from '@circle-fin/borrow-kit'

try {
  await kit.registerWebhook({
    from: { adapter, chain: 'Arc_Testnet' },
    loanId: '11111111-1111-4111-8111-111111111111',
    webhookUrl: 'https://example.com/webhook',
  })
} catch (error) {
  if (isKitError(error) && error.code === BorrowError.OWNER_MISMATCH.code) {
    console.error('The connected wallet is not the loan owner.')
  }
  throw error
}

Manage integrator fees

import {
  BorrowError,
  BorrowKit,
  BorrowServiceProvider,
  getErrorCode,
} from '@circle-fin/borrow-kit'

// The API key is the integrator's identity on these routes, so it has to reach
// the provider. Supply it either on the provider or per request via config.apiKey.
const borrowKit = new BorrowKit({
  providers: [new BorrowServiceProvider({ apiKey: process.env.CIRCLE_API_KEY })],
})

try {
  const current = await borrowKit.getIntegratorConfig()
} catch (error) {
  if (getErrorCode(error) !== BorrowError.CONFIG_NOT_FOUND.code) throw error
  await borrowKit.setIntegratorConfig({
    integratorFeeBps: 25,
    integratorFeeAddress: '0x1111111111111111111111111111111111111111',
  })
}

These are the only wallet-free, key-required calls in the kit. There is no adapter, chain, or signature: the Borrow Service resolves the integrator from the API key itself, never from a request field.

getIntegratorConfig throws BorrowError.CONFIG_NOT_FOUND when no configuration exists yet. That is the expected first-run state; create one with setIntegratorConfig, which upserts.

The fee rate and recipient travel together. Supplying a rate without a recipient, or a recipient without a rate, is a validation error.

Close a loan

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const borrowKit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

const closeQuote = await borrowKit.getCloseLoanQuote({
  chain: 'Arc_Testnet',
  loanId: '11111111-1111-4111-8111-111111111111',
  slippageBps: 300,
})

const closed = await borrowKit.closeLoan({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  slippageBps: 300,
})

getCloseLoanQuote reports bundledRepayment: the most the close will pull from the wallet. Borrow Service prices the debt at quote time and the figure is raised by slippageBps (300 bps by default), so the headroom is already in the number — fund the wallet to at least this much, and the SDK refuses to submit an envelope that pulls more.

It is a ceiling, not an invoice. Interest accrues before the close settles and a full close is denominated in Morpho shares whose asset value is only fixed when Morpho runs; the headroom absorbs that drift, and whatever goes unspent is swept back in the same batch. amountRepaid on the confirmed result is what was actually paid, and it is the only binding figure of the two.

closeLoan reads the current allowance and Morpho authorization, then submits one atomic batch containing an exact USDC approval when needed, temporary Morpho authorization, the Circle-signed full-close execution, and authorization revocation. The returned repayment and collateral amounts are the actual on-chain values decoded from the confirmed Morpho receipt: amountRepaid is what was really paid — the binding figure of the two against the quote's bundledRepayment ceiling — and collateralWithdrawn is what the position actually released. Only token identity comes from the non-binding quote. Inspect the result status: submitted means the batch was sent but its outcome is not yet known, while confirmed includes the decoded amounts. confirmed-details-unavailable means the close transaction landed on-chain and includes its transaction hash, but the post-close receipt and position could not be decoded — whether the loan is now fully closed is unknown, closed is always false here, and the close must not be retried.

A confirmed result also carries closed and position, the latter read back from Morpho once the batch settled rather than assumed from the request. closed is true only when both the debt and the collateral read back as zero. If the close left either behind, closed is false and position reports what remains — the close itself still succeeded and must not be retried. That read takes no oracle price, so ltv, healthFactor and liquidationPrice on position are always null, and healthFactorBand is null too whenever debt remains.

Withdraw collateral

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const borrowKit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

const quote = await borrowKit.getWithdrawCollateralRepayIfNeededQuote({
  chain: 'Arc_Testnet',
  loanId: '11111111-1111-4111-8111-111111111111',
  collateralAmount: '0.5',
})

const withdrawal = await borrowKit.withdrawCollateralRepayIfNeeded({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  collateralAmount: '0.5',
})

A withdrawal can repay your loan, and a full one always does. The operation does not refuse a release the position cannot support. It sheds whatever debt is needed to keep the position healthy once the collateral is gone, and bundles the repayment into the same signed envelope — the "repay if needed" the name carries. Release every unit of collateral and the repayment clears the debt outright, which leaves the loan with neither debt nor collateral and closes it on the service side. Use closeLoan when that is the intent; it is the operation named for it and its result reports closed directly.

Two fields on the quote tell you which case you are in before you submit:

  • resultingHealthFactor is null when the withdrawal leaves no debt at all. Null is a settled loan, not a missing value. resultingLtv and liquidationPrice go null with it, and resultingBand reads SAFE — nothing is left that could be liquidated, which looks the same as a healthy margin but is not.
  • bundledRepayment above the loan's outstanding debt means the same thing. The service caps the repayment at the position's full borrow shares, so anything beyond the debt is the slippage tolerance absorbing interest that accrues before the batch lands.

withdrawCollateralRepayIfNeeded is a combined operation and the only standalone withdrawal path; closeLoan also returns collateral, as part of closing. Pass only collateralAmount: there is no repayment field, and supplying one is a validation error.

bundledRepayment on the quote is the most the withdrawal will pull from the wallet: the repayment the service sized to keep the position healthy, raised by the slippage tolerance that covers drift between quoting and settlement. Fund the wallet to at least that much, and the SDK refuses to submit an envelope that pulls more. Unspent headroom is swept back in the same batch, and amountRepaid on the confirmed result is what was actually paid. It is zero for an over-collateralized withdrawal, which releases collateral without repaying anything.

slippageBps bounds how far that sized repayment may drift between quoting and settlement. The service applies it when it sizes the repayment, and the quote's bundledRepayment carries the same tolerance, so one number covers both. Omit it and the SDK sends 300 (3%) explicitly, so the quote and the execution are priced against the same number rather than one the service picks for itself. Pass 0 and it is honored as no drift buffer: the service sizes the repayment exactly and signs an envelope with no headroom. Choose it only for exact-or-fail behaviour, since debt that accrues between the quote and execution can then make the write revert.

Both bounds are checked before anything is approved or submitted. The quote must name the collateral amount that was requested, and the signed execution must agree with it. Its Morpho withdrawal leg has to release that same amount of that same token, from the caller's own position, paying out to the Adapter Contract. The envelope may carry nothing but Morpho repay and withdrawal legs, all in the same market, and may not pull more USDC than the quote's bundledRepayment. That figure is a conservative ceiling: the service applies the slippageBps tolerance when it sizes the repayment, and the wire boundary applies it again to cover the accrual between the quote call and the execution call, which price against separate market reads. assertWithinRequestedBounds then compares the required allowance against it exactly rather than widening it once more. Any mismatch throws and submits nothing.

Morpho pays the collateral to the Adapter Contract, which forwards it on. That second hop is checked too. The Adapter returns only the tokens the envelope lists, so the envelope has to list the collateral, and the USDC as well whenever it pulls any: the Adapter takes the whole approved amount and refunds the unspent part the same way. Every listed token must name the caller. An envelope that would leave either token in the Adapter is rejected rather than submitted, because the batch would otherwise succeed with the funds stranded.

Debiting a Morpho position requires the Adapter Contract to be an authorized manager, so the batch is [approve?, setAuthorization, execute, revoke]. The grant and the revocation are unconditional, so no authorization is ever left standing outside the atomic batch. Finding one already in place before submission is an error: the operation throws BorrowError.STANDING_AUTHORIZATION and submits nothing.

The result carries no closed flag, unlike closeLoan, so read the loan back with getLoans if you need to confirm a full withdrawal settled it.

withdrawCollateralRepayIfNeeded returns a discriminated union, so branch on status:

  • confirmed carries txHash plus collateralWithdrawn and amountRepaid. Their amounts are decoded from the Morpho receipt; only token identity comes from the non-binding quote. amountRepaid is zero when the position was over-collateralized enough to need no repayment.
  • confirmed-details-unavailable carries txHash when the batch succeeded but the receipt could not be decoded. The withdrawal happened; do not retry it.
  • submitted carries batchId when the wallet accepted the batch but reported no receipt. Resolve it with the wallet's own EIP-5792 wallet_getCallsStatus before retrying; the kit has no method that takes a batchId. Retrying through retry(error) is already safe without an idempotencyKey; pass one only when the retry happens outside this process.

Add collateral

import { BorrowKit } from '@circle-fin/borrow-kit'
import { createViemAdapterFromPrivateKey } from '@circle-fin/adapter-viem-v2/next'

const borrowKit = new BorrowKit()
const adapter = createViemAdapterFromPrivateKey({
  privateKey: process.env.PRIVATE_KEY as string,
})

const quote = await borrowKit.getAddCollateralQuote({
  chain: 'Arc_Testnet',
  loanId: '11111111-1111-4111-8111-111111111111',
  collateralAmount: '0.25',
})

const addition = await borrowKit.addCollateral({
  from: { adapter, chain: 'Arc_Testnet' },
  loanId: '11111111-1111-4111-8111-111111111111',
  collateralAmount: '0.25',
})

addCollateral supplies collateral to an existing loan, which lowers its LTV and moves it away from liquidation. It is the simplest of the writes. Crediting a Morpho position is permissionless, so there is no authorization pair, and the service sizes no second leg, so there is no slippageBps: passing one is a validation error.

The batch is [reset?, approve?, execute]. The approval is skipped when the wallet has already granted the Adapter Contract enough allowance. When a smaller allowance is already standing, it is reset to zero first: this is the only write whose token comes from the market rather than the chain, and tokens following the USDT pattern reject a non-zero to non-zero approve.

Unlike every other write, this one pulls the loan's collateral token rather than USDC. The quote is what names that token, so the allowance read, the approval, and the check on the signed envelope all key off it. The envelope must pull exactly the requested amount of exactly that token, priced in that token's own decimals rather than USDC's six. Digits past that precision are truncated on this path (so '0.2500000001' on an 8-decimal token becomes '0.25'), unlike withdrawCollateralRepayIfNeeded, which refuses the extra digits.

The signed Morpho leg is decoded and checked before anything is approved or submitted: it must be a single supplyCollateral call, for that amount, of that token, crediting the caller's own position. Checking the amount alone would not catch an envelope that deposits the caller's collateral into someone else's loan. Any mismatch throws and submits nothing.

addCollateral returns a discriminated union, so branch on status:

  • confirmed carries txHash and collateralAdded. The amount is the request normalized to the collateral token's precision, not a decoded receipt value: Morpho credits exactly what the checked envelope says, so there is nothing to decode.
  • submitted carries batchId when the wallet accepted the batch but reported no receipt. The deposit may still have landed; do not treat it as failed. Resolve it with the wallet's own EIP-5792 wallet_getCallsStatus before retrying; the kit has no method that takes a batchId.

There is no confirmed-details-unavailable here, unlike withdrawCollateralRepayIfNeeded and closeLoan. Those decode amounts from the receipt and need a state for a decode that fails; this one reads no receipt, so that state cannot arise.

A retry through retry(error) is safe without an idempotencyKey — the SDK generates one and replays the original request rather than depositing a second time. Pass your own only when the retry happens outside this process.

License

This project is licensed under the Apache 2.0 License. Contact support for details.