@circle-fin/borrow-kit
v1.0.0
Published
SDK for Circle Borrow Kit operations
Keywords
Readme
Borrow Kit
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-kitInstall 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 viemQuick 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:
resultingHealthFactoris null when the withdrawal leaves no debt at all. Null is a settled loan, not a missing value.resultingLtvandliquidationPricego null with it, andresultingBandreadsSAFE— nothing is left that could be liquidated, which looks the same as a healthy margin but is not.bundledRepaymentabove 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:
confirmedcarriestxHashpluscollateralWithdrawnandamountRepaid. Their amounts are decoded from the Morpho receipt; only token identity comes from the non-binding quote.amountRepaidis zero when the position was over-collateralized enough to need no repayment.confirmed-details-unavailablecarriestxHashwhen the batch succeeded but the receipt could not be decoded. The withdrawal happened; do not retry it.submittedcarriesbatchIdwhen the wallet accepted the batch but reported no receipt. Resolve it with the wallet's own EIP-5792wallet_getCallsStatusbefore retrying; the kit has no method that takes abatchId. Retrying throughretry(error)is already safe without anidempotencyKey; 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:
confirmedcarriestxHashandcollateralAdded. 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.submittedcarriesbatchIdwhen 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-5792wallet_getCallsStatusbefore retrying; the kit has no method that takes abatchId.
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.
