Borrow SDK
Offer a Bitcoin-backed credit line in your app with the headless Surge Borrow SDK.
Access is gated to approved partners - reach out to the Surge team for a read-only npm token and test credentials. The shapes below match the shipped v0.1.
The SDK is headless and signer-agnostic: it never holds keys, renders no UI, and runs in the browser, Node, and React Native. You bring a Bitcoin taproot signer, an EVM signer, and a storage adapter; the SDK builds each intent, calls your signer, and talks to the Surge relayer.
Scope: BTC collateral in a per-user taproot vault, USDC on Base (6 decimals), variable + fixed markets, and the full lifecycle - deposit + initial borrow, add collateral, borrow more, repay, extend, withdraw - on signet + Base Sepolia (test) and Bitcoin mainnet + Base (production).
How it works
- Session. The user signs one Sign-In-with-Ethereum message and a session JWT is issued (persisted via your storage adapter, reused across restarts). The Bitcoin key comes from your
btcSigner. There is no Bitcoin signature at login - the withdraw PSBT is the only one in the whole flow. - Vault. The SDK derives the taproot deposit address locally from the user's key.
- Deposit + borrow. One signed intent covers collateral + initial borrow. The user sends BTC to the vault from any wallet; once it confirms, Surge opens the position on Base and USDC lands at the user's EVM address.
- Manage. Read collateral, debt, LTV and rate; borrow more, repay and withdraw, extend the term, and pull BTC back out.
Every action is a single await - inject the two signers once, and the SDK handles build → sign → submit inside each call. Almost every action takes a positionId (see Position ID).
Install and configure
// npm install @surgecredit/borrow-sdk viem
import { createBorrowClient } from "@surgecredit/borrow-sdk";
const client = createBorrowClient({
network: "signet", // "signet" | "mainnet"
storage: window.localStorage, // optional in the browser; see below
// evmRpcUrl / btcApiUrl are optional - omit to use the network's public defaults
});network-"signet"(Bitcoin signet + Base Sepolia) or"mainnet"(Bitcoin mainnet + Base).evmRpcUrl/btcApiUrl- optional. Omitted, they fall back to public defaults (which rate-limit - pass your own for production). The relayer API, Surge price oracle, and Supabase project are fixed per network and not configurable.storage- where the session token is kept so users don't re-sign in on every restart. Optional in the browser (falls back tolocalStorage); required on React Native and Node.
interface StorageAdapter {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}On React Native use a secure store (encrypted at rest), not plaintext AsyncStorage. Don't pass sessionStorage - it clears on close, logging the user out every restart.
viem is the only peer dependency; the core client has no framework dependencies.
Signers
Two small interfaces, both required. BtcSigner is satisfied by anything that can Schnorr-sign a PSBT; EvmSigner by anything that signs EIP-191/EIP-712. A key you derive yourself, a wallet SDK, or a server-side signer all work the same way - the SDK never holds keys and doesn't care where the signature comes from.
interface BtcSigner {
address: string;
publicKey: string; // x-only taproot pubkey (32-byte hex)
signPsbt(psbtBase64: string, inputs: TapInputRef[]): Promise<string>;
}
interface EvmSigner {
address: `0x${string}`;
signMessage(message: string): Promise<`0x${string}`>; // EIP-191
signTypedData(data: Eip712TypedData): Promise<`0x${string}`>; // EIP-712
}| Side | Requirement |
|---|---|
| EVM | An EOA that signs EIP-191 messages and EIP-712 typed data. Borrowed USDC is disbursed to this address. (Smart-contract / ERC-1271 wallets aren't supported yet.) |
| Bitcoin | A taproot (P2TR) account that exposes its x-only pubkey and script-path Schnorr-signs a PSBT the SDK provides. The BTC key must use the BIP-86 path for the network - signet m/86'/1'/0'/0/0, mainnet m/86'/0'/0'/0/0 - or the derived vault won't match. |
Address types: the user's Bitcoin key must be taproot; the deposit can be funded from any wallet or address type; withdrawals pay out to any address (P2TR, SegWit, or legacy). Reference signer adapters (MetaMask, Unisat, mnemonic) ship in the SDK's examples/playground.
Session
const session = await client.createSession({ evmSigner, btcSigner });
// SIWE login (one EVM signature) -> JWT, persisted and resumable.
session.evmAddress; // 0x…
session.btcPublicKey; // x-only hex
// First-time users register a relayer profile (idempotent to check):
if (!(await session.userExists())) await session.registerUser();Vault
const vault = session.getVault(); // local derivation, no network call
// { vaultId, depositAddress, scriptTree, network, exitTimelockBlocks }Position ID
A credit line is identified by its Position ID (the position NFT). Get it once a line is open and pass it to every action:
const positionId = await session.getPositionId(); // string | null (null until open)Deposit and first borrow
The first borrow is bundled with the deposit - there's no standalone borrow call. List markets, size the collateral with getRequiredCollateral, then create the deposit:
The minimum borrow is $5 (MIN_BORROW_USD) — that's the only floor on opening a line; there's no minimum on the deposit itself. createDeposit rejects less before prompting for a signature.
const markets = await session.getMarkets();
// [{ marketId, kind: "variable" | "fixed", borrowRateApr,
// maxLtvBps, liquidationThresholdBps, active }]
const { requiredSats } = await session.getRequiredCollateral({
marketId: markets[0].marketId,
amountUsd: "1000",
});
const { depositId, vaultAddress } = await session.createDeposit({
collateralSats: requiredSats,
borrowAmountUsd: "1000",
marketId: markets[0].marketId,
durationDays: 90,
});
// show vaultAddress + QR; the user sends BTC from any wallet.
const stop = session.watchDeposit(depositId, (s) => {
// s.status advances; s.nft_id is set once the position mints
if (s.nft_id) stop();
});On confirmation Surge opens the position and USDC is disbursed to the session's EVM address - confirm it landed with getUsdcBalance():
const { usd } = await session.getUsdcBalance(); // { raw, usd, owner, tokenAddress }Use getDepositStatus(depositId) for a one-shot status check, or getActiveDeposit() to find and resume a pending deposit after a reload (it looks the wallet's deposit up by EVM address).
Read the position
const position = await session.getPosition(positionId);
// { positionId, isActive, marketId, collateralSats, debtUsd, principalUsd,
// interestUsd, ltvPercent, maxBorrowUsd, collateralValueUsd, debtValueUsd,
// maxLtvBps, loanExpiresAt, expired, exitBlock }
const quote = await session.getBorrowQuote({ marketId, amountUsd: "500" });
// { marketId, amountUsd, borrowRateApr, maxLtvBps }Debt accrues at the position's market rate. Statuses are absolute data (amounts, timestamps, block heights), so a UI re-rendering from history always derives current state correctly.
Add collateral
Send more BTC to the same vault address, then sync it in. No signature - it's a relayer-side reconcile.
await session.addCollateral({ positionId });
// { positionId, status, confirmedSats, onchainSats, unconfirmedSats }
session.watchCollateralSync(positionId, (u) => {/* watching -> success */});Adding collateral raises maxBorrowUsd. Use syncCollateral(positionId) to reconcile any drift on load.
Borrow more
await session.borrowMore({ positionId, amountUsd: "250" }); // { positionId, txHash }Pre-checked against the live LTV before the wallet is prompted - a draw over the max fails with LTV_EXCEEDED first.
To decide which call to make, quote it first: getRequiredCollateral({ marketId, amountUsd, positionId }) returns requiredSats: 0n when the position already has enough - draw with borrowMore. Otherwise send requiredSats more BTC to the vault and use borrowMoreSync({ positionId, amountUsd }) + watchBorrowMoreSync(...), which draws and syncs the new collateral in one flow.
Re-borrowing after full repayment
While a loan is active, the position is locked to its market. Once it's fully repaid (debt is 0), it no longer carries an active market, so the user can pick a new one before drawing again. Switch the market first, then borrow:
// only valid on a zero-debt position; the relayer rejects it while a loan is active
await session.switchMarket({ positionId, marketId }); // { positionId, marketId, confirmed }
await session.borrowMore({ positionId, amountUsd: "250" });switchMarket writes the chosen market to the position on-chain and confirms it before returning, so the subsequent draw uses the new market. Drive your UI from the position's debt: show a market picker when debtUsd is 0, and lock it to the current market otherwise.
Repay
The user signs two EIP-712 payloads (repay authorization + USDC EIP-3009 transfer) and Surge relays the transaction. Overpayment is capped to the live debt.
await session.repay({ positionId, amountUsd: "200" }); // { txHash }Withdraw BTC
Two signatures: an EIP-712 authorization (relayed by Surge) and a PSBT signature (the vault repayment path is a 2-of-2 between the user and the Surge key, which co-signs after validating the request).
const { medium } = await session.getFeeRates(); // { fast, medium, slow } sat/vB
const { txid } = await session.withdraw({
positionId,
toBtcAddress,
amountSats: 200_000n,
sendAll: false, // true sweeps the whole vault
feeRate: medium, // omit to auto-fetch a ~3-block rate
});
// { txid, resumed, withdrawnSats }
session.watchWithdrawal(positionId, (u) => {/* … -> finalized | failed | expired */});toBtcAddress is validated before any signature — empty, malformed, wrong-network, and the user's own vault address all throw INVALID_WITHDRAW_ADDRESS up front, so a doomed destination never costs a signature or leaves a pending withdrawal behind. Guard the vault case in your UI too: it's a valid Bitcoin tx, so nothing downstream would reject it — the vault would pay itself, the collateral never leaves, and the miner fee is burned.
Before prompting btcSigner.signPsbt, the SDK verifies the PSBT (outputs, amounts, change back to the vault). If a withdrawal is left unfinalized, getPendingWithdrawal(positionId) detects it and resumeWithdrawal(...) completes the Bitcoin leg without a fresh EVM signature.
Extend the credit line
Refresh the term on an open position without repaying; debt and collateral carry over.
await session.extendCreditLine({ positionId }); // { evmTxHash?, btcTxid, alreadyPending }
session.watchExtension(positionId, (u) => {/* … -> confirmed | finalized */});Throws EXTENSION_ALREADY_PENDING if one is already in flight. Drive the renewal prompt from position.loanExpiresAt, not a stored flag.
Live activities
A position's ongoing credit-line events - the same feed the app shows in its header. Each activity carries a coarse state and a ready-made label.
const activities = await session.getActivities(positionId);
// [{ eventName, status, state: "in_progress" | "succeeded" | "failed", label }]
const stop = session.watchActivities(positionId, (activities) => {
// full list each tick; polls ~30s; auto-stops when all terminal
});eventName is one of sync_collateral, borrow_more, withdrawal, credit_line_extension.
Utilities
Helpers for wiring up your UI. None are required - they're conveniences so you don't re-derive protocol maths.
| Method | Returns |
|---|---|
getRequiredCollateral({ marketId, amountUsd, positionId? }) | BTC to lock for a draw. Sized by that market's max LTV, priced at the Surge oracle, plus a 1.5% safety buffer. With positionId it returns only the extra collateral to send - existing collateral subtracted, existing debt counted in - and requiredSats: 0n means the position already has enough. |
getBtcPriceUsd() | The Surge oracle's BTC/USD rate - what the protocol values collateral at. Read on-chain via evmRpcUrl. |
getUsdcBalance(owner?) | USDC balance of the EVM signer - where borrowed USDC lands. |
getFeeRates() | { fast, medium, slow } BTC fee rates in sat/vB. |
const q = await client.getRequiredCollateral({ marketId: 1, amountUsd: "10", positionId });
q.collateralValueUsd // "14.29" total needed: (debt + 10) / maxLtv
q.existingCollateralUsd // "5.00" already in the vault
q.additionalCollateralUsd // "9.29" the gap
q.requiredSats // 10472n <- send thisMax LTV is per-market, so marketId decides the answer - the same $10 needs ~1/8 more collateral at 70% LTV than at 80%. For a fully-repaid position you intend to switch, pass the target market; while debt is live the market is locked and quoting a different one throws.
What your signers sign
Every watch* returns an unsubscribe fn - call it on unmount to stop polling.
| Signer method | Signs | Used by |
|---|---|---|
evmSigner.signMessage | EIP-191 message | SIWE login; the action envelope on deposit, borrow-more, and extend |
evmSigner.signTypedData | EIP-712 typed data | repay (×2: authorization + USDC EIP-3009), withdraw authorization |
btcSigner.signPsbt | Taproot script-path PSBT (Schnorr) | withdraw only - the single Bitcoin signature |
Reads, addCollateral, syncCollateral, and switchMarket need no signature (the session JWT authorizes them).
Liquidation
Liquidation is settled by Surge and cannot be user-driven: the vault's liquidation leaf is a single-sig path spendable by Surge alone, and there's no relayer route. There is nothing to do - only state to read, and actions the SDK refuses.
const { inLiquidation, liquidated, type, at } = await session.getLiquidation(positionId);| Field | Meaning |
|---|---|
inLiquidation | The collateral is being seized right now. Gate every credit action on this. |
liquidated | A liquidation settled in the position's current term (already scoped - see below). |
type | "full_liquidation" (LTV breach) or "delinquency" (term ended unrepaid). |
at | ISO timestamp of the in-term liquidation, or null. |
isActive: false does not mean liquidated - it's equally true of a position that was simply repaid and closed. getLiquidation is what tells them apart. Position.inLiquidation carries the live flag if you already have the position.
Errors
Every flow throws a typed BorrowError with a stable .code (plus .status and .details). Branch on .code, never on the message.
| Code | When |
|---|---|
SESSION_UNAUTHENTICATED | No valid session (401) - create or resume it first |
NETWORK_ERROR | The request failed to reach the relayer, or an RPC read failed (offline / rate-limited RPC) |
GATEWAY_ERROR | A gateway error not covered by a specific code (see .status) |
DEPOSIT_ALREADY_ACTIVE | A deposit is already in flight - resume it instead |
EXTENSION_ALREADY_PENDING | An extension is already pending |
LTV_EXCEEDED | The borrow/withdraw would exceed the max loan-to-value |
INSUFFICIENT_COLLATERAL | The action would leave the position under-collateralized |
INSUFFICIENT_USDC_BALANCE | Not enough USDC in the wallet to repay |
VAULT_NOT_CONFIRMED | Acting before the collateral deposit has confirmed |
VAULT_ADDRESS_MISMATCH | Local vault re-derivation disagreed - usually a wrong BTC derivation path |
WITHDRAWAL_AMOUNT_MISMATCH | The PSBT amount ≠ the authorized pending withdrawal |
POSITION_IN_LIQUIDATION | The collateral is being seized - no credit action applies |
POSITION_EXPIRED | The term ended (isActive: false) and collateral remains - extendCreditLine renews it |
POSITION_CLOSED | The position is closed with no collateral left - nothing to renew, createDeposit opens a new line |
WITHDRAWAL_ALREADY_PENDING | A withdrawal is authorized on-chain and spends the same vault UTXOs |
INSUFFICIENT_MARKET_LIQUIDITY | The draw exceeds what the market can lend right now |
INVALID_WITHDRAW_ADDRESS | toBtcAddress is empty, malformed, wrong-network, or your own vault address |
SIMULATION_REVERT | The on-chain simulation reverted before submit |
SIGN_REQUEST_EXPIRED | A signature came back after the payload expired - retries with a fresh one |
NONCE_REUSED | A signed action envelope was replayed |
Pre-flight validation runs before the SDK calls your signer, so the wallet is never prompted for an action that will fail.
Gating. Every mutating call first checks the position can actually take it, and throws rather than prompting. Liquidation is checked always and reported first - the collateral is already being taken, so it outranks every other state. Whether the position is still live, a pending withdrawal, and market liquidity are checked where they apply: a pending withdrawal blocks extendCreditLine (both spend the same vault UTXOs) but not repay, since repaying is how a holder de-risks. You don't have to pre-check to be safe - reading the state is how you avoid offering an action that will throw.
When a position ends. position.isActive is the actionability signal: it goes false when the term ends, when the line is repaid and closed, or when a liquidation settles. Drawing against any of those is refused; repay, withdraw, and extendCreditLine stay open, because renewing is the way out. Whether you get POSITION_EXPIRED or POSITION_CLOSED depends on whether collateral remains to renew against.
Catching the code is enough to be safe, but the throw is a backstop rather than a UI. To avoid offering an action that will throw, read the state first:
const p = await client.getPosition(positionId);
if (p.inLiquidation) {
// Nothing to do - the collateral is being seized. client.getLiquidation() for detail.
} else if (p.isActive) {
// Live: draw / repay / withdraw as normal.
} else if (p.collateralSats > 0n) {
await client.extendCreditLine({ positionId }); // renew the term, collateral intact
} else {
// Closed with nothing left - open a new credit line with createDeposit().
}isActive tells you it's over; collateralSats tells you which remedy. Check inLiquidation first - it can be true on a still-active position, and it outranks everything.
Don't gate on position.expired - it reports term status (has loanExpiresAt passed), not usability. The two come apart in both directions: a position can be past its term and still fully live, and a settled one keeps a future expiry date while being dead. Use expired to warn about a lapsing term; use isActive to decide what's allowed.
Testing on signet
Integrate against signet first. Signet BTC is free from public faucets, and testnet USDC lives on Base Sepolia (the Circle faucet at faucet.circle.com covers it). A full lifecycle on signet - deposit through withdraw - is the acceptance bar before mainnet access. Note the Surge app is mainnet-only, so for signet you verify against the SDK itself rather than the app.
v0.1

