@surgecredit/borrow-sdk
v0.1.11
Published
Headless SDK for Surge's Bitcoin-backed USDC credit line. Bring your own EVM + Bitcoin signer and your own UX.
Readme
@surgecredit/borrow-sdk
Headless, signer-agnostic SDK for Surge's Bitcoin-backed USDC credit line. Bring your own EVM + Bitcoin signer and your own UX. Runs in the browser, Node, and React Native.
Preview release. Publicly installable from npm; use is governed by the LICENSE.
The SDK never holds keys. You bring an EVM signer, a Bitcoin (taproot) signer, and a storage adapter; the SDK builds each intent, calls your signer, and talks to the Surge relayer.
Integrating this into an app? Read in order: Prerequisites → Runtime setup → Signers → Lifecycle → Flows and their states → API reference → Go-live checklist.
Building screens? Flows and their states is the section that saves you the most time. Most flows return before they finish, and it says exactly what each one returns, what the user must do next, and when to stop the spinner.
Install
@surgecredit/borrow-sdk is a public package on the npm registry. No access token or .npmrc setup is needed.
npm install @surgecredit/borrow-sdk viemviem is the only peer dependency. Publishing to the npm registry is Surge-only; use of the SDK is governed by the LICENSE.
Prerequisites — skills & knowledge required
This is not a plug-and-play widget. Budget for a developer who is comfortable across both an EVM chain and Bitcoin taproot. Concretely, the integrator should be competent with:
| Area | What you need to know |
| --- | --- |
| TypeScript / modern JS | ES modules, async/await, promises, typed generics. The SDK is TS-first and ships .d.ts. |
| EVM wallets | EIP-1193 providers, viem (peer dependency) or ethers, EIP-191 personal_sign, and EIP-712 typed-data signing. Familiarity with the Base chain (8453 mainnet / 84532 Sepolia). |
| Bitcoin taproot | Either (a) integrating a taproot wallet (Unisat / Xverse / Leather) that exposes signPsbt, or (b) if you sign yourself: P2TR, PSBTs, BIP-86 derivation (m/86'/…), Schnorr signatures, x-only pubkeys, and script-path (tapleaf) spends. |
| Session / auth flow | SIWE (Sign-In With Ethereum) mental model — one EVM signature mints a JWT session that the SDK persists and reuses. |
| Runtime / bundler config | Polyfilling Buffer for the browser (bitcoinjs-lib needs it); secure key-value storage on React Native. |
| Async lifecycle | The SDK exposes watch* pollers that return an unsubscribe fn — you must wire these into your component/screen lifecycle (e.g. React useEffect cleanup). |
| Operational security | Never logging signatures/seeds, and understanding that borrowed USDC is disbursed to the EVM signer's address. |
Environment & runtime support
| Requirement | Notes |
| --- | --- |
| Node | >= 18 (see engines). |
| Peer dependency | viem@^2.21 — install it alongside the SDK. |
| Module format | Dual ESM + CJS, with types for both. Works with Vite, Next.js, Node, and React Native/Expo. |
| Runtimes | Browser, Node, and React Native. Each has a setup caveat — see Runtime setup. |
| Networks | "signet" (Bitcoin signet + Base Sepolia, for testing) and "mainnet" (Bitcoin mainnet + Base). Build and test on signet first. |
Runtime setup
Do this first — it prevents the most common crash.
Browser (Vite, Next.js, Webpack)
bitcoinjs-lib requires a global Buffer (and global/process). Without it you'll get a runtime crash like Buffer is not defined the first time the SDK derives a vault or builds a withdraw PSBT.
Vite (what the playground uses):
// vite.config.ts
import { nodePolyfills } from "vite-plugin-node-polyfills";
export default defineConfig({
plugins: [nodePolyfills({ globals: { Buffer: true, global: true, process: true } })],
});Next.js / Webpack — provide Buffer and add the buffer package:
// next.config.js
const webpack = require("webpack");
module.exports = {
webpack(config) {
config.plugins.push(new webpack.ProvidePlugin({ Buffer: ["buffer", "Buffer"] }));
return config;
},
};React Native / Expo
- Polyfill
Buffer(e.g. thebufferpackage, imported in your entry file) and installreact-native-get-random-values(imported before any crypto use). - Use a secure storage adapter (see StorageAdapter) — not an in-memory one.
- Prefer delegating PSBT signing to a wallet SDK; native Schnorr signing needs a working
@bitcoinerlab/secp256k1/WASM path on device.
Model
You inject two signers — an EvmSigner (EIP-191 + EIP-712) and a BtcSigner (taproot script-path PSBT) — once, at session creation. The SDK builds each intent, calls your signer, and submits. Every action is a single await; there is no SignRequest/submit() round-trip.
- Auth: one EVM SIWE signature (Supabase
signInWithWeb3) mints the session JWT, persisted via yourstorageadapter and reused across reloads (no re-prompt for the same wallet). No Bitcoin signature at login — the withdraw PSBT is the only Bitcoin signature. - Endpoints are optional: pass your own
evmRpcUrl/btcApiUrl, or omit them and the SDK falls back to public defaults per network (Base RPC + esplora on signet, mempool.space on mainnet). Public endpoints rate-limit — bring your own for production. The relayer API and price oracle are fixed per network. - BTC fees are yours to choose: fetch tiers with
getFeeRates()and pass afeeRate(sat/vB) towithdraw/extendCreditLine, or omit it and the SDK auto-fetches. - Position ID: the SDK's term for a credit-line position (it maps to the position NFT). Get it with
getPositionId()and pass it aspositionIdto every action.
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 EvmSigner {
address: `0x${string}`;
signMessage(message: string): Promise<`0x${string}`>; // SIWE + envelopes
signTypedData(data: Eip712TypedData): Promise<`0x${string}`>; // deposit/repay/withdraw/extend
}
interface BtcSigner {
address: string; // the WALLET's own address, for this network (tb1p… on signet)
publicKey: string; // x-only hex — this is what the vault is derived from
signPsbt(psbtBase64: string, inputs: TapInputRef[]): Promise<string>; // withdraw only
}Full, copy-ready adapters live in examples/playground/signers/ — MetaMask, Unisat, and a mnemonic signer (they ship in the package, so after install you'll find them under node_modules/@surgecredit/borrow-sdk/examples/playground/signers/). The essentials:
EvmSigner (from viem / an EIP-1193 wallet)
import { createWalletClient, custom } from "viem";
import type { EvmSigner, Eip712TypedData } from "@surgecredit/borrow-sdk";
export function evmSignerFromProvider(eth: any, address: `0x${string}`): EvmSigner {
const wallet = createWalletClient({ account: address, transport: custom(eth) });
return {
address,
signMessage: (message) => wallet.signMessage({ account: address, message }),
signTypedData: (data: Eip712TypedData) =>
wallet.signTypedData({
account: address,
domain: data.domain,
types: data.types as any,
primaryType: data.primaryType,
message: data.message,
}),
};
}Chain match: wallets sign EIP-712 only when their active network matches the domain
chainId(Base 8453 / Base Sepolia 84532). Switch the wallet to the right chain (wallet_switchEthereumChain, adding it withwallet_addEthereumChainif unknown) beforecreateSession. Seesigners/metamask.ts.
BtcSigner (from a taproot wallet, e.g. Unisat)
import type { BtcSigner, TapInputRef } from "@surgecredit/borrow-sdk";
export function btcSignerFromUnisat(unisat: any, address: string, xOnlyPubkey: string): BtcSigner {
return {
address,
publicKey: xOnlyPubkey, // 32-byte x-only hex (drop the 02/03 prefix from a compressed key)
signPsbt: async (psbtBase64, inputs: TapInputRef[]) => {
const signedHex = await unisat.signPsbt(/* hex */ psbtToHex(psbtBase64), {
autoFinalized: false,
toSignInputs: inputs.map((i) => ({ index: i.index, address })),
});
return hexToPsbtBase64(signedHex);
},
};
}Taproot derivation path — critical
The SDK derives the vault from your BtcSigner.publicKey. It must be a BIP-86 taproot key:
- signet:
m/86'/1'/0'/0/0(coin type1'= testnet/signet) - mainnet:
m/86'/0'/0'/0/0
Use the wrong path and the derived vault address won't match Surge's — so the BTC you send won't back your position.
Unisat gotcha: Unisat derives taproot at
m/86'/0'/…(coin type0') even on signet, so its default address will not match. On signet, set Unisat's custom HD path tom/86'/1'/0'/0/0(Restore/Import → Custom HdPath) before connecting. On mainnet the standardm/86'/0'/0'/0/0is correct — this rule is signet-only. Seesigners/mnemonic.tsfor the exact derivation.How you'll notice: check
btcSigner.addressagainst the network as soon as you connect. Abc1p…address while you're on"signet"means the wallet is on mainnet, sopublicKeyis from the wrong path and the derived vault will not be the protocol's. Refuse to continue rather than letting the user fund a vault Surge does not recognise:const wantsPrefix = network === "mainnet" ? "bc1p" : "tb1p"; if (!btcSigner.address.startsWith(wantsPrefix)) { throw new Error(`Switch your Bitcoin wallet to ${network} before continuing.`); }
Self-custody signing (advanced)
If you sign PSBTs yourself rather than via a wallet, signPsbt must script-path Schnorr-sign each TapInputRef against the repayment tapleaf (SIGHASH_DEFAULT, tapleaf hash of tapLeafScriptHex) and return the updated PSBT. signers/mnemonic.ts is a complete, correct reference. The withdraw PSBT is the only Bitcoin signature in the entire flow.
StorageAdapter
The SDK persists the SIWE JWT so users aren't re-prompted every reload. Provide a key-value store:
interface StorageAdapter {
getItem(key: string): string | null | Promise<string | null>;
setItem(key: string, value: string): void | Promise<void>;
removeItem(key: string): void | Promise<void>;
}- Browser:
window.localStoragesatisfies it directly. - React Native: wrap
expo-secure-store/@react-native-async-storage/async-storage(a secure store is preferred — the token is a bearer credential). - Node / server: provide your own (Redis, encrypted file, etc.).
- Omit it and the session lives in memory only (re-login every restart) — fine for a quick spike, not for production.
Don't pass sessionStorage — it clears on close, logging the user out every restart.
Lifecycle
The happy path, in order. Reads need no wallet; everything else is on the session.
import { createBorrowClient } from "@surgecredit/borrow-sdk";
// 1. Configure once.
const client = createBorrowClient({
network: "signet",
storage: window.localStorage,
// evmRpcUrl / btcApiUrl optional — pass your OWN for production
// (the public defaults rate-limit)
});
// 2. Wallet-free reads (browse markets, quote a borrow) — optional, pre-login.
const markets = await client.getMarkets();
// 3. Bind signers + SIWE login → a session (persisted; reused across reloads).
// On page load, try to restore first — that never prompts:
const session =
(await client.restoreSession({ evmSigner, btcSigner })) ??
(await client.createSession({ evmSigner, btcSigner })); // prompts only if needed
// 4. First-time users: register the relayer profile (idempotent check).
if (!(await session.userExists())) await session.registerUser();
// 5. Size the collateral, then open the credit line.
// Minimum borrow is $5; there's no minimum on the deposit itself.
const { requiredSats } = await client.getRequiredCollateral({
marketId: markets[0].marketId,
amountUsd: "25",
});
const { depositId, vaultAddress } = await session.createDeposit({
collateralSats: requiredSats,
borrowAmountUsd: "25",
marketId: markets[0].marketId,
// durationDays is NOT a user choice — omit it. See "Credit-line term" below.
});
// → show `vaultAddress` to the user; they send BTC to it.
// 6. Wait for the deposit to confirm and the position to mint.
const stopDeposit = session.watchDeposit(depositId, (status) => {
if (status.nft_id) stopDeposit(); // position minted
});
// 7. The Position ID underpins every subsequent action.
const positionId = await session.getPositionId();
// 8. Manage the line.
await session.borrowMore({ positionId, amountUsd: "25" });
await session.repay({ positionId, amountUsd: "5" });
// 9. Withdraw collateral (the only BTC signature).
const { medium } = await session.getFeeRates();
const { txid } = await session.withdraw({ positionId, toBtcAddress, amountSats: 10_000n, feeRate: medium });
const stopWithdraw = session.watchWithdrawal(positionId, (u) => {
if (["finalized", "failed", "expired"].includes(u.status)) stopWithdraw();
});
// 10. Surface live status anywhere (header feed): deposit/borrow/withdraw/extend.
const stopActivities = session.watchActivities(positionId, (activities) => {
render(activities); // [{ eventName, status, state, label }]
});Watcher discipline: every watch* returns an unsubscribe fn. Always call it on unmount / navigation to stop polling. See Flows and their states for what each flow returns, when it is really finished, and which failures to design for:
useEffect(() => {
const stop = session.watchActivities(positionId, setActivities);
return stop; // React cleanup
}, [positionId]);API reference
createBorrowClient(config): BorrowClient
The one-call facade. Configure network + endpoints + storage once; every read and flow inherits them.
interface BorrowClientConfig {
network: "signet" | "mainnet";
evmRpcUrl?: string; // Base RPC — optional; falls back to the network's public default
btcApiUrl?: string; // esplora-compatible BTC API — optional; default is Surge esplora
// on signet, mempool.space on mainnet
storage?: StorageAdapter; // localStorage / SecureStore / AsyncStorage — enables session persistence
siwe?: SiweOptions; // { statement?, url? } for the SIWE message
}Endpoints & defaults
evmRpcUrl and btcApiUrl are optional. When omitted, the SDK uses the network's public defaults (configured in the SDK, not something you set up).
| Network | evmRpcUrl default | btcApiUrl default |
| --- | --- | --- |
| signet | https://sepolia.base.org (Base Sepolia) | https://esplora.signet.surge.dev (esplora) |
| mainnet | https://mainnet.base.org (Base) | https://mempool.space/api (mempool, esplora-compatible) |
The defaults are convenient for getting started, but public endpoints rate-limit — pass your own evmRpcUrl / btcApiUrl for production traffic. This bites earlier than you might expect: getMarkets() issues one eth_call per market index, and that single burst is enough to trip a shared endpoint and throw RATE_LIMITED on your first screen. The BTC/USD rate needs no endpoint: it's read from the oracle contract over evmRpcUrl, so a production RPC covers it too.
Client methods
The client has wallet-free reads plus createSession. The reads are also available on the session, so once you have a session you can call them there too — the client is just for using them before (or without) signing in.
| Method | Needs wallet? | Description |
| --- | --- | --- |
| getMarkets() | no | List borrow markets (Market[]: id, kind, borrowRateApr, maxLtvBps, liquidationThresholdBps, availableLiquidityUsd). An active market isn't necessarily a fundable one — check availableLiquidityUsd (the pool's own getMaxBorrowAmount, which accounts for liquidity moving between markets) before sizing a draw. |
| getPosition(positionId) | no | On-chain read of a position → Position (collateral, debt, LTV, health, maxBorrowUsd, exitBlock, expiry, inLiquidation). maxBorrowUsd is the absolute ceiling, not a recommended draw — taking all of it lands the position at exactly the market's max LTV, one price tick from unhealthy. Reserve a margin before presenting it as "available" (COLLATERAL_BUFFER_MULTIPLIER is the 1.5% the SDK itself uses when sizing collateral). |
| getLiquidation(positionId) | no | Whether the collateral is being — or has been — seized → { inLiquidation, liquidated, type, at, collateralSats, debtStable }. See Liquidation. |
| getBorrowQuote({ marketId, amountUsd }) | no | Quote a borrow → { borrowRateApr, maxLtvBps }. |
| getRequiredCollateral({ marketId, amountUsd, positionId? }) | no | BTC to lock for a draw — sized by that market's max LTV, priced at the Surge oracle, × 1.015 buffer. With positionId returns only the extra to send (existing collateral subtracted, debt counted in); requiredSats: 0n means it already has enough. |
| getBtcPriceUsd() | no | The Surge oracle's BTC/USD rate — what the protocol values collateral by. Read on-chain via evmRpcUrl. |
| getFeeRates() | no | BTC fee tiers { fast, medium, slow } in sat/vB. |
| restoreSession({ evmSigner, btcSigner }) | no prompt | Rebuild a session from the persisted JWT, or null if there isn't one. Never prompts — call it on load so a returning user isn't shown a sign-in screen they've already been through. |
| createSession({ evmSigner, btcSigner }) | yes | Inject signers + run SIWE login; returns a BorrowSession with every wallet-bound action bound. Reuses a persisted token when there is one, so it only prompts on a genuinely new session. |
Session methods
Created via client.createSession(...). Exposes the client reads (getMarkets / getPosition / getBorrowQuote / getRequiredCollateral / getBtcPriceUsd / getFeeRates) plus all wallet-bound actions. readonly evmAddress and readonly btcPublicKey are available on the session object.
Account & identity
| Method | Description |
| --- | --- |
| getPositionId() | This wallet's Position ID, or null until a credit line is open. Resolved from the deposit record (keyed on the EVM address). |
| userExists() | Whether this wallet (by BTC public key) already has a relayer profile → boolean. |
| registerUser(params?) | Create this wallet's relayer profile. Call after userExists() returns false. |
| getVault(nonce?) | Derive the taproot vault deposit address + vaultId (no network call). |
| getUsdcBalance(owner?) | This wallet's USDC balance → { raw: bigint, usd: string, owner, tokenAddress }. Borrowed USDC is disbursed here, so it's how you confirm a draw landed. Defaults to the session's EVM address. |
| getAccessToken() | Current session JWT (auto-refreshed), or null. |
| signOut() | End the Supabase session. |
Open & draw credit
| Method | Description |
| --- | --- |
| createDeposit(params) | Open a credit line. params: { collateralSats: bigint, borrowAmountUsd, marketId, durationDays?, nonce? } — omit durationDays, see Credit-line term. Returns { depositId, vaultAddress, resumed } — send BTC to vaultAddress. Minimum borrow $5 (MIN_BORROW_USD), rejected before any signature; there's no minimum on the deposit. Size collateralSats with getRequiredCollateral(). |
| getDepositStatus(depositId) | One-shot status of a specific deposit. Prefer this once you hold a depositId from createDeposit. |
| getActiveDeposit() | Discovery lookup — the wallet's current deposit record (DepositStatus) or null, by EVM address. Use when you don't have a depositId (e.g. after a page reload). |
| watchDeposit(depositId, onUpdate, opts?) | Poll a deposit until the position mints (status.nft_id set). Returns an unsubscribe fn. |
| borrowMore({ positionId, amountUsd }) | Draw more against a position that already has enough collateral. Returns { positionId, txHash }. |
| borrowMoreSync({ positionId, amountUsd }) | Draw more and add collateral in one flow (when the position needs more BTC first). Returns { positionId, status, resumed }. |
| switchMarket({ positionId, marketId }) | Switch a zero-debt position's market before re-borrowing (a fully-repaid position re-picks a market; the relayer rejects it while a credit line is active). Confirms the change on-chain. Returns { positionId, marketId, confirmed }. |
| watchBorrowMoreSync(positionId, onUpdate, opts?) | Poll a borrow-more-sync to completion. Returns an unsubscribe fn. |
| getBorrowMoreSyncStatus(positionId) | One-shot borrow-more-sync status (null if no record yet). |
Collateral
| Method | Description |
| --- | --- |
| addCollateral({ positionId }) | Sync BTC you sent to the vault into the position on-chain. Returns { positionId, status, confirmedSats, onchainSats, unconfirmedSats }. |
| syncCollateral(positionId) | Reconcile on-chain collateral with the vault's actual BTC (fix a mismatch / call on load). Same route as addCollateral; idempotent. Returns SyncCollateralResult (confirmedSats vs onchainSats). |
| watchCollateralSync(positionId, onUpdate, opts?) | Poll a collateral sync (watching → waiting_confirmations → success). Returns an unsubscribe fn. |
| getCollateralSyncStatus(positionId) | One-shot collateral-sync status (null if no record yet). |
Repay
| Method | Description |
| --- | --- |
| repay({ positionId, amountUsd }) | Repay — two EIP-712 signatures (repay authorization + EIP-3009 ReceiveWithAuthorization). Returns { txHash }. |
Withdraw
| Method | Description |
| --- | --- |
| withdraw({ positionId, toBtcAddress, amountSats, sendAll?, feeRate?, nonce? }) | Full withdraw: EVM auth → taproot 2-of-2 PSBT → Schnorr sign → MPC co-sign → broadcast → notify. Auto-detects partial vs full sweep and clamps sub-dust remainders. Rejects a destination that is empty, malformed, wrong-network, or your own vault address — before any signature. Returns { txid, resumed, withdrawnSats }. |
| getPendingWithdrawal(positionId) | Detect an unfinalized on-chain withdrawal → { pending, pendingSats }. Use to show a "Retry" affordance. |
| resumeWithdrawal({ positionId, toBtcAddress, feeRate?, nonce? }) | Retry/complete a pending withdrawal — BTC leg only, no fresh EVM auth. |
| watchWithdrawal(positionId, onUpdate, opts?) | Poll withdrawal status to finalized/failed/expired. Returns an unsubscribe fn. |
| getWithdrawStatus(positionId) | One-shot withdrawal status (null if no record yet). |
Extend
| Method | Description |
| --- | --- |
| extendCreditLine({ positionId, feeRate?, validitySeconds?, nonce? }) | Extend the credit line term: an EVM request + a BTC self-spend that resets the vault's exit timelock. Throws EXTENSION_ALREADY_PENDING if one is already pending. The contract can also refuse the extension (for example when the position's LTV is too high to extend); that revert surfaces after the EIP-712 signature, not before it. Returns { btcTxid, evmTxHash? }. |
| resumeExtension({ positionId, feeRate?, nonce? }) | Finish an interrupted extension. The EVM request sets pendingExtension on-chain; if the Bitcoin leg then fails (declined prompt, dropped connection, MPC failure) the position is stuck — the contract reverts a fresh request with PendingExtension. Runs the Bitcoin leg only, no new EVM signature. Symptom: getExtensionStatus() shows status: "pending" with btcTxHash: null. |
| watchExtension(positionId, onUpdate, opts?) | Poll the extension to confirmed/finalized (or failed/expired). Returns an unsubscribe fn. |
| getExtensionStatus(positionId) | One-shot extension status (null if no record yet). |
Activities (live feed)
A position's ongoing credit-line activities — the same feed the app shows in its "Live Activities" header (deposit/collateral sync, borrow-more, withdrawal, extension), with an in-progress/succeeded/failed state and a ready-made label per row.
| Method | Description |
| --- | --- |
| getActivities(positionId) | One-shot list of the position's current activities → Activity[] ([] if none / not indexed yet). |
| watchActivities(positionId, onUpdate, opts?) | Poll the activity feed for live updates — emits the full list each tick and auto-stops once every activity is terminal. Returns an unsubscribe fn. opts: { intervalMs? (default 30000), timeoutMs? (default 30m), stopWhenAllTerminal? (default true) }. |
Each Activity is { eventName, status, state, label }:
eventName—"sync_collateral" | "borrow_more" | "withdrawal" | "credit_line_extension".status— the raw relayer status (e.g."watching","broadcasted","finalized").state— coarse bucket:"in_progress" | "succeeded" | "failed"(filter ongoing rows without knowing every raw status).label— a human-readable string matching the app (e.g."Monitoring Deposits","Deposit Failed","Withdrawal Complete","Credit Line Extended").
// One-shot
const activities = await session.getActivities(positionId);
const ongoing = activities.filter((a) => a.state === "in_progress");
// Live (polls every 30s; stops when all activities finish or on unmount)
const stop = session.watchActivities(positionId, (activities) => {
render(activities); // full list each tick — one row per event type
});
// call stop() to unsubscribe early (e.g. React useEffect cleanup)The endpoint is
GET /v1/activities/:positionIdand requires the session JWT, so these live on the session, not the wallet-free client. The relayer is REST-only, so "live" means polling (no websockets/SSE).
Standalone functions & utilities
The client/session cover the full flow; these top-level exports are for lower-level or no-wallet use.
| Export | Description |
| --- | --- |
| getNetworkConfig(network) | Network config (chain id, contract addresses, relayer URL, vault/NUMS keys). |
| deriveVaultAddress({ userBtcPublicKey, evmAddress, nonce?, network }) | Derive the taproot vault deposit address off-chain. |
| getVaultId(evmAddress, nonce) | Compute the vaultId (keccak256(address, nonce)). |
| fetchFeeRates(esploraUrl) | Fetch { fast, medium, slow } fee rates directly. |
| MIN_BORROW_USD | Smallest borrow the protocol accepts ($5). |
| COLLATERAL_BUFFER_MULTIPLIER | The 1.5% safety margin getRequiredCollateral applies. |
| getMarkets / getPosition / getBorrowQuote / getRequiredCollateral / getBtcPriceUsd | The read functions the client wraps (take network + { evmRpcUrl }). |
| getUsdcBalance(network, owner, { evmRpcUrl }) | USDC balance of any address. |
| getVault({ userBtcPublicKey, evmAddress, network, nonce? }) | Vault info from local derivation — no network call, so no options. |
| getActiveDeposit / getPositionId / createDeposit / borrowMore / withdraw / … | The flow functions the session wraps (take a FlowContext). |
| createSupabaseAuth(params) | The SIWE/JWT auth layer (advanced — the client builds this for you). |
| createTransport(params) | The relayer HTTP transport (JWT + signed-envelope). |
| BorrowError / BorrowErrorCode | Typed error class + code union thrown by every flow. |
Types
All params/results are exported, e.g. Position, Market, BorrowQuote, RequiredCollateral, RequiredCollateralParams, UsdcBalance, PriceOptions, VaultInfo, ReadOptions, FeeRates, BorrowMoreParams, RepayParams, ExtendCreditLineParams, WithdrawParams, WithdrawResult, DepositStatus, SyncCollateralResult, Activity, ActivityEventName, ActivityState, WatchActivitiesOptions, SwitchMarketParams, SwitchMarketResult, EvmSigner, BtcSigner, StorageAdapter, BorrowClientConfig, BorrowClient, BorrowSession.
Flows and their states
Read this before building any screen. Every table below is what the Surge app actually does, and the shape the playground demonstrates.
The single most common integration mistake is treating these as request/response. Most are not: the call returns when the relayer accepts the job, and the outcome arrives later, over a poller, often after the user does something (sends BTC) or the chain does (mines a block). A screen that renders the return value and stops will freeze at the moment of the click.
At a glance
| Flow | Wallet prompts | Returns when | Then what |
| --- | --- | --- | --- |
| createDeposit | 1 EVM (envelope) | The deposit record exists | User sends BTC. watchDeposit until nft_id |
| addCollateral / syncCollateral | none | The relayer starts watching | User sends BTC. watchCollateralSync |
| borrowMore | 1 EVM (envelope) | The draw is submitted | Done; re-read the position |
| borrowMoreSync | 1 EVM (envelope) | The job is queued | User sends BTC. watchBorrowMoreSync |
| switchMarket | none | The switch is confirmed on-chain | Done (it polls internally) |
| repay | 2 EVM (typed data) | The relayer broadcast it | Done; re-read the position |
| withdraw | 1 EVM + 1 BTC per input | The BTC tx is broadcast | watchWithdrawal to finalized |
| extendCreditLine | 1 EVM + 1 BTC | The refresh tx is broadcast | watchExtension |
Prompt counts matter for your copy: repay asks for two signatures (the repay
authorisation and an EIP-3009 USDC transfer authorisation), and users abandon when a
second prompt appears unannounced.
Two different meanings of "done"
There are two, they disagree, and mixing them up hangs your UI:
import { classifyStatus, isFlowTerminal } from "@surgecredit/borrow-sdk";
isFlowTerminal("borrowMoreSync", "submitted"); // true — the watcher has STOPPED
classifyStatus("submitted"); // "in_progress" — not a stated outcomeisFlowTerminal(flow, status)answers "will another update ever arrive?". Use it to decide whether to keep showing a spinner. This is the set eachwatch*uses.classifyStatus(status)answers "was it good or bad?". Use it to pick success or failure styling.
borrowMoreSync stops on submitted and confirmed, and extension stops on
confirmed — all three of which classifyStatus calls in_progress. Poll with a
watcher and render with classifyStatus alone and you get a spinner that never stops,
because the poller has already given up. Gate the spinner on isFlowTerminal.
watchDeposit is the exception: a deposit is done when the position mints, which
appears as nft_id turning non-null rather than as a status. Use isDepositTerminal.
The pattern every async flow follows
// 1. Start it. This returns fast; it does NOT mean the flow finished.
await session.borrowMoreSync({ positionId, amountUsd });
// 2. Tell the user what THEY must do next. For anything that needs collateral,
// that is "send BTC to this address" — the SDK cannot do it for them.
const { depositAddress } = session.getVault();
// 3. Poll, and settle the UI on the flow's OWN terminal set.
const stop = session.watchBorrowMoreSync(positionId, (u) => {
const done = isFlowTerminal("borrowMoreSync", u.borrowMoreStatus);
setSpinner(!done);
setTone(classifyStatus(u.borrowMoreStatus)); // succeeded | failed | in_progress
if (done) refreshPosition();
});
// 4. ALWAYS unsubscribe on unmount.
return stop;On-chain reads lag the relayer
repay, borrowMore and withdraw return once the relayer has broadcast. The
reads (getPosition, getUsdcBalance, getWithdrawable) are on-chain, so refreshing
immediately after the call usually still shows the pre-action numbers, and users report
it as "nothing happened".
Refresh again on a short backoff:
await doTheAction();
await refresh();
[3000, 9000].forEach((ms) => setTimeout(refresh, ms));Resumability
Three flows can be interrupted after the EVM leg has landed, which reserves state on-chain. Check for that on load, not just after an action, or the user is stuck:
| Check on load | Means | Resume with |
| --- | --- | --- |
| getPendingWithdrawal(positionId) | A withdrawal is authorised on-chain. This alone does NOT mean it is stuck — the record stays set from the EVM authorization until the relayer reconciles the confirmed BTC tx, so it is also the normal state of one in flight. Cross-check getWithdrawStatus(): a null btcTxHash (or a failed status) is the stuck signal. | resumeWithdrawal only when stuck; otherwise watchWithdrawal and wait |
| getActiveDeposit() | A deposit is open and unfunded | Show its address again and watchDeposit |
| getExtensionStatus(positionId) | An extension is pending. If btcTxHash is null the Bitcoin leg never broadcast and it will NOT self-heal | resumeExtension when btcTxHash is null; otherwise watchExtension and do not start another |
withdraw itself detects a pending withdrawal and fulfils it rather than erroring, so
it is safe to call again — but the amount is fixed on-chain and your requested
amount is ignored.
"Pending on-chain" is not the same as "stuck"
Both resumable flows record state on-chain the moment their EVM leg lands, and that record stays until the relayer reconciles a confirmed Bitcoin transaction. So the on-chain flag is set for the entire normal lifetime of the flow, not just when something has gone wrong. Offering a "Resume" button on the flag alone means offering it during every healthy withdrawal.
The signal that something is actually stuck is the absence of a broadcast transaction:
const [pending, status] = await Promise.all([
session.getPendingWithdrawal(positionId),
session.getWithdrawStatus(positionId),
]);
const inFlight = !!status?.btcTxHash && classifyStatus(status.status) !== "failed";
const stuck = pending.pending && !inFlight; // authorised, nothing broadcastThe same test applies to extensions: getExtensionStatus() with status: "pending"
and btcTxHash: null means the Bitcoin leg never went out, and it will not self-heal.
Failure states worth designing for
These are ordinary, not exceptional, and each has a different remedy:
| What you'll see | Meaning | What the user does |
| --- | --- | --- |
| sync_collateral → timeout | The BTC never arrived in the window | Send the BTC, then start again. Nothing was lost |
| LTV_EXCEEDED on borrowMore | Not enough collateral for this draw | Switch to borrowMoreSync (see below) |
| POSITION_EXPIRED | Term ended, collateral remains | extendCreditLine — the only action available |
| WITHDRAWAL_ALREADY_PENDING | An authorised withdrawal is in flight | resumeWithdrawal, or wait for expiry |
| INVALID_WITHDRAW_ADDRESS | Empty, malformed, wrong-network, or the user's own vault | Fix the address. Thrown before any signature |
Every one is thrown before a signature where the SDK can tell in advance, so gate your
buttons on state (position.isActive, maxBorrowUsd, getPendingWithdrawal) and the
throw becomes a backstop rather than the UX.
Drawing more than your collateral supports
borrowMore draws only against collateral already in the vault. Ask for more than
position.maxBorrowUsd and it throws LTV_EXCEEDED before any signature.
The remedy is usually a different flow rather than a smaller number. borrowMoreSync
adds collateral and draws in one go, which is what the Surge app does when it sends a
user to "add collateral" mid-draw:
const position = await session.getPosition(positionId);
// Both are decimal USD strings. Compare them as scaled integers, not via Number():
// at the boundary, binary floating point rounds and routes you to the wrong flow.
const toScaled = (usd: string) => {
const [whole, fraction = ""] = usd.split(".");
return BigInt(whole) * 1_000_000n + BigInt(fraction.slice(0, 6).padEnd(6, "0"));
};
if (toScaled(amountUsd) <= toScaled(position.maxBorrowUsd)) {
await session.borrowMore({ positionId, amountUsd }); // enough collateral already
} else {
// How much MORE Bitcoin is needed. Passing positionId subtracts what the vault
// already holds and counts existing debt, so this is the top-up, not the total.
const { requiredSats, requiredBtc } = await session.getRequiredCollateral({
marketId: position.marketId!,
amountUsd,
positionId,
});
// Show the user requiredBtc + the vault address, and have them send it.
const { depositAddress } = session.getVault();
// Then one call adds the collateral and draws:
await session.borrowMoreSync({ positionId, amountUsd });
const stop = session.watchBorrowMoreSync(positionId, (u) => { /* render u.status */ });
}Check maxBorrowUsd before enabling your Draw button and you can route the user
without relying on the throw at all.
Credit-line term
The term is fixed by the protocol — do not ask your users to pick one.
The taproot vault's exit leaf carries a hard-coded relative timelock of
EXIT_CSV_BLOCKS (52,416 blocks ≈ 364 days). The Bitcoin side is locked for that
long regardless of what the EVM loan records, so there is exactly one sensible term:
CREDIT_LINE_TERM_DAYS (360 days), which sits just inside the timelock.
createDeposit supplies it, so omit durationDays entirely. Both constants are
exported if you want to display the term ("360-day credit line"):
import { CREDIT_LINE_TERM_DAYS, EXIT_CSV_BLOCKS } from "@surgecredit/borrow-sdk";Passing a longer term would let the loan outlive the Bitcoin timelock; a much shorter one expires the credit line while the collateral is still frozen. Only pass a value if you deliberately want a shorter tenure and understand that trade-off.
Vault nonce
nonce (on createDeposit, withdraw, extendCreditLine, getVault) selects which
taproot vault you mean. The vault address is derived from your BTC public key, your EVM
address and this number, so a different nonce is a different address holding different
UTXOs.
Leave it at 0 unless you are deliberately running more than one vault for the same wallet. Passing a nonce you did not fund derives an empty vault, and the flow will report no UTXOs rather than doing anything dangerous.
DepositStatus
What getDepositStatus / getActiveDeposit / watchDeposit give you:
| Field | Meaning |
| --- | --- |
| id | Deposit id — the handle for getDepositStatus / watchDeposit. |
| address | The vault address the user must send BTC to. |
| status | Raw relayer status (e.g. watching, confirmed, failed). |
| confirmations / expected_confirmations | Confirmation progress, for a progress bar. |
| nft_id | null until the position mints. Non-null is the signal the credit line is open — it's the Position ID. |
| loan_amount / collateral_amount | Amounts the relayer recorded, as strings. |
| tx_hash | Funding BTC transaction, once seen. |
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 is no relayer route. So there is nothing to do — only state to read, and actions the SDK refuses.
const { inLiquidation, liquidated, type, at } = await client.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, or whose term ended. getLiquidation is what tells them apart.
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 before a signature; repaying and withdrawing stay open, and extendCreditLine is available as the way to renew. Available is not the same as guaranteed: the contract still decides whether a given position can be extended.
Which error you get depends on whether there's collateral left to renew against:
| State | Error | The way forward |
| --- | --- | --- |
| isActive: false, collateral remains | POSITION_EXPIRED | extendCreditLine is available to renew the term (the contract can still refuse it). The collateral is untouched and still theirs. |
| isActive: false, no collateral | POSITION_CLOSED | Nothing to renew — createDeposit opens a new line. |
You get this for free by catching the code — the flows refuse before any signature:
try {
await session.borrowMore({ positionId, amountUsd: "50" });
} catch (e) {
if (e.code === "POSITION_EXPIRED") await session.extendCreditLine({ positionId });
if (e.code === "POSITION_CLOSED") { /* open a new line with createDeposit */ }
}But the throw is a backstop, not a UI. To avoid offering an action that will throw — what the Surge app does when it hides its Draw button — 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 session.extendCreditLine({ positionId }); // available to renew; may still be refused on-chain
} else {
// Closed with nothing left — open a new credit line.
}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.
Gate on isActive, not on a term timestamp. isActive covers every end state at once — term ended, repaid and closed, or liquidation settled. Split the remedies with collateralSats (collateral remaining ⇒ extendCreditLine() is available to try; none ⇒ closed) and getLiquidation() (settled by auction vs a clean lapse). The term-end block height is available as exitBlock.
Error handling
Every flow and read throws a typed BorrowError with a .code, an optional .status (HTTP), and .details. Handle by code.
message is product copy — short, plain, safe to show a user. Developer guidance lives on details.hint, and the full request path on details.path; neither is meant for your UI. Branch on code and write your own copy when you need to.
import { BorrowError } from "@surgecredit/borrow-sdk";
try {
await session.borrowMore({ positionId, amountUsd: "500" });
} catch (e) {
if (e instanceof BorrowError) {
switch (e.code) {
case "LTV_EXCEEDED": return toast("Amount exceeds your borrowing power.");
case "SESSION_UNAUTHENTICATED": return relogin(); // re-run createSession
case "NETWORK_ERROR": return retryWithBackoff();
default: report(e); // e.status, e.details
}
}
throw e;
}| code | Typically raised when | Recommended handling |
| --- | --- | --- |
| SESSION_UNAUTHENTICATED | Relayer returned 401 — session missing/expired. | Re-authenticate (createSession → SIWE). |
| NETWORK_ERROR | fetch or an RPC read failed — offline, DNS, or an unreachable endpoint. Reads are wrapped too, so a viem failure never reaches you untyped. | Retry with backoff; check connectivity and use your own evmRpcUrl. |
| GATEWAY_ERROR | Any non-2xx not covered by a specific code. | Inspect e.status + e.details; surface a generic error. |
| INVALID_PARAMS | A parameter is malformed: a positionId that is not a positive whole number, an amountUsd that is empty/zero/negative or carries more than 6 decimals, a non-integer amountSats. Thrown before any signature or request. | Fix the input. This is a caller bug, not a protocol state — an empty string is the common cause, since BigInt("") is 0n and would otherwise act on position 0. |
| NOT_FOUND | Relayer returned 404 (or auth_not_owner): the id does not exist, or it belongs to a different wallet. The relayer answers 404 either way so it does not confirm the resource exists. | Check the id against getPositionId(). Do not retry with the same id. |
| RATE_LIMITED | Relayer returned 429, or an EVM RPC throttled a read. The public default endpoints throttle quickly. | Back off and retry; pass your own evmRpcUrl for anything beyond a spike. |
| RELAYER_UNAVAILABLE | Relayer returned 503, or is missing config for the route. Surge-side. | Retry with backoff; nothing was signed away. |
| RELAYER_OUT_OF_GAS | The relayer's gas wallet cannot fund the broadcast. Surge-side. | Retry shortly. The user's signature was not consumed on-chain. |
| DEPOSIT_ALREADY_ACTIVE | Opening a deposit while one is already in flight. | Resume via getActiveDeposit() / watchDeposit() instead of creating a new one. |
| EXTENSION_ALREADY_PENDING | An extension is pending on-chain (pendingExtension[nftId]). Thrown before any signature for borrowMore, borrowMoreSync and withdraw too, not just extendCreditLine — the VaultManager reverts all three while one is pending. | watchExtension() the existing one; don't start another. |
| LTV_EXCEEDED | The draw exceeds what the collateral already in the vault supports (position.maxBorrowUsd). | Either lower the amount, or switch flows: borrowMore only draws against existing collateral, while borrowMoreSync adds collateral and draws in one go. Size the top-up with getRequiredCollateral({ marketId, amountUsd, positionId }). |
| INSUFFICIENT_COLLATERAL | Action needs more collateral than the vault holds. | Add collateral and sync before retrying. |
| INSUFFICIENT_USDC_BALANCE | Repay with too little USDC in the wallet. | Prompt the user to top up USDC. |
| VAULT_NOT_CONFIRMED | Acting before the collateral BTC has confirmed. | Wait for confirmations / syncCollateral, then retry. |
| VAULT_ADDRESS_MISMATCH | Derived vault address ≠ expected. | Almost always a wrong BTC derivation path — verify the BIP-86 path. |
| WITHDRAWAL_AMOUNT_MISMATCH | Vault-removed sats ≠ authorized withdraw amount. | Re-fetch fee rates / UTXOs and rebuild; don't hand-tune amounts. |
| INVALID_FEE_RATE | A feeRate that is zero, negative, non-finite, or above the 1000 sat/vB ceiling (thrown before any signature); or a withdrawal whose fee exceeds Surge's ceiling of 5x the current half-hour rate, which the relayer rejects at the MPC co-signing step. | Check the units: feeRate is sat/vB, not sats. Fetch a real tier with getFeeRates(), or omit it and let the SDK auto-fetch. Note the relayer-side case fires after the EVM authorization, so a pending withdrawal exists: resumeWithdrawal() with an acceptable rate, or let it expire. |
| INVALID_WITHDRAW_ADDRESS | toBtcAddress is empty, malformed, wrong-network, or the user's own vault address. Thrown before any signature. | Validate the field in your UI too. The vault case is the one to guard: it's a valid Bitcoin tx, so nothing downstream would reject it — the vault would pay itself, collateral never leaves, and the fee is burned. |
| POSITION_IN_LIQUIDATION | Any credit action on a position whose collateral is being seized. Thrown before any signature. | Nothing to retry — liquidation is settled by Surge. Read getLiquidation() and show the state instead of an action. |
| POSITION_EXPIRED | Two cases, both before any signature: the position is isActive: false while collateral remains, or its loanExpiry timestamp has passed. | extendCreditLine renews the term and is the only action available until it does — the contract can still refuse it (for example on LTV). Repay and withdraw are not alternatives here: repayWithERC3009Single reverts PositionNotActive, and Surge's signer will not co-sign the Bitcoin leg of a withdrawal for an inactive or expired position. |
| POSITION_CLOSED | Same, but the position holds no collateral — there's nothing to renew. | createDeposit to open a new credit line. If you expected collateral, getLiquidation() says whether an auction took it. |
| WITHDRAWAL_ALREADY_PENDING | Extending or drawing while a withdrawal is authorized on-chain — it spends the same vault UTXOs. | Let it finish or expire. withdraw() itself resumes a pending one instead of erroring. |
| INSUFFICIENT_MARKET_LIQUIDITY | A draw exceeds the market's availableLiquidityUsd. | Reduce the amount, or pick a market with liquidity. |
| SIMULATION_REVERT | The on-chain simulation reverted before submit. | The action would fail on-chain — check params/position state. |
| SIGN_REQUEST_EXPIRED | The signed action envelope's short validity window elapsed. | Retry the action (it re-signs a fresh envelope). |
| NONCE_REUSED | An action envelope nonce was already consumed. | Usually a double-submit — retry once with a fresh action. |
Always branch on code (stable), not on message (human-readable, may change).
Input validation
Every flow parses its parameters before touching a signer or the network, and throws INVALID_PARAMS on anything malformed. The rules mirror the relayer's own schemas, so the SDK refuses exactly what the gateway would refuse, but without spending a wallet prompt to find out.
The parsers are exported if you want to validate a form field with the same rule:
import { parsePositionId, parseAmountUsd, BorrowError } from "@surgecredit/borrow-sdk";
try {
parseAmountUsd(input); // "" , "0", "-5" and "1.2345678" all throw
} catch (e) {
if (e instanceof BorrowError) setFieldError(e.message);
}parseAmountUsd rejects more than 6 decimal places rather than rounding, because parseUnits("1.2345678", 6) silently rounds and would change the amount the user signs for.
Production go-live checklist
- [ ] Signet first. Complete a full open → fund → borrow → repay → withdraw cycle on
"signet"before touching mainnet. - [ ] Your own RPC/BTC endpoints. Pass
evmRpcUrlandbtcApiUrl— the public defaults rate-limit and are not for production traffic. The BTC/USD rate needs no separate endpoint (it's read from the Surge oracle contract overevmRpcUrl), so a production RPC covers collateral sizing too — and a rate-limited RPC would break it. - [ ] Buffer polyfill verified in your production bundle (not just dev).
- [ ] Secure storage for the session token (secure store on RN; not in-memory).
- [ ] Correct BTC derivation path per network (see Taproot derivation) — verify the derived vault address matches before funding real BTC.
- [ ] Chain-switch UX — force the EVM wallet onto Base (mainnet) / Base Sepolia (signet) before signing.
- [ ] Error handling wired for every
BorrowError.codeyou can hit (see Error handling), with a sane fallback. - [ ] Watcher cleanup — every
watch*unsubscribe is called on unmount. - [ ] No secrets in logs — never log signatures, PSBTs, JWTs, or seeds.
- [ ] npm token is read-only, scoped, in CI secrets, and rotated on a schedule.
- [ ] Idempotency — the flows tolerate resume (
resumeWithdrawal,getActiveDeposit,getPendingWithdrawal); use them rather than blind retries.
Troubleshooting
| Symptom | Likely cause / fix |
| --- | --- |
| Buffer is not defined | Missing browser polyfill — see Runtime setup. |
| Derived vault address doesn't match / VAULT_ADDRESS_MISMATCH | Wrong BTC derivation path (see Taproot derivation). On signet, set Unisat to m/86'/1'/0'/0/0. |
| EIP-712 signing rejected / wallet error | Wallet is on the wrong chain — switch to Base / Base Sepolia first. |
| SESSION_UNAUTHENTICATED right after login | Storage adapter not persisting, or token expired — check storage; re-createSession. |
| Cross-origin / CORS errors in the browser | Shouldn't happen — both relayers send Access-Control-Allow-Origin: * and name Authorization in Access-Control-Allow-Headers, and the price oracle is read on-chain (JSON-RPC is CORS-open). If you hit one it's server-side: report it to Surge with the failing response headers. |
| Watchers never stop / requests pile up | You didn't call the returned unsubscribe fn — wire it to lifecycle cleanup (see Lifecycle). |
| npm install 404 on @surgecredit/borrow-sdk | A stale @surgecredit registry/auth line in a local or global .npmrc. The package is public now, so remove it (see Install). |
Playground
examples/playground is a guided, phased reference (Setup → Draw credit → Manage → Exit) that walks the full lifecycle against live signet and shows the SDK call behind each action. It includes MetaMask, Unisat, and mnemonic signer adapters, and ships in the package (under node_modules/@surgecredit/borrow-sdk/examples/playground). Run it from a clone of the SDK repo with npm run play.
Scripts
npm run build # tsup -> dual CJS/ESM + .d.ts / .d.cts
npm run typecheck # tsc --noEmit
npm test # vitest
npm run play # run the browser playground