curvance
v5.3.2
Published
A SDK to explore the curvance protocol efficently & easily.
Maintainers
Readme
A TypeScript SDK for interacting with the Curvance protocol. It uses ethers v6 and a setup-bound cache model: setupChain() loads market state up front, snapshots the chain configuration it used, and returns markets whose reads are synchronous until an explicit refresh runs.
❯ Install
$ npm install --save curvance❯ Supported Chains
Chain identifiers use Alchemy-style prefixes:
| Chain | Identifier | Support |
|---|---|---|
| Monad Mainnet | monad-mainnet | Production mainnet; setup/read, rewards, Kyber-backed simple zaps/leverage where configured |
| Arbitrum Sepolia | arb-sepolia | Testnet read/setup surface; DEX routes fail closed through UnsupportedDexAgg |
Adding a production chain is an explicit SDK release task. A chain is not supported just because a wallet or app can switch to it. The SDK needs:
- a
chain_configentry andchain_rpc_configentry with matchingchainId - a contract manifest under
src/contracts - Curvance API service aliases or an explicit disabled state
- DEX service config, fee/checker policy, or
UnsupportedDexAgg - native/wrapped-native and vault metadata in chain config
- route-matrix tests for advertised zap/leverage support
- fork or live-read proof once deployments exist
❯ Quick Start
import { setupChain } from "curvance";
import { ethers } from "ethers";
const wallet = new ethers.Wallet(privateKey, provider);
const { chain, chainId, setupConfigSnapshot, markets, reader, dexAgg, global_milestone } =
await setupChain("monad-mainnet", wallet);setupChain signature:
setupChain(
chain: ChainRpcPrefix,
provider: curvance_provider | null = null, // signer (wallet) OR read-only provider; null → SDK default
api_url: string = "https://api.curvance.com",
options: {
feePolicy?: FeePolicy; // default is setup-resolved; Kyber chains require checker-compatible policy
account?: address | null; // user address for user-specific reads without a signer
readProvider?: curvance_read_provider | null; // explicit override for read transport
} = {}
): Promise<{
chain: ChainRpcPrefix,
chainId: number,
setupConfigSnapshot: Readonly<SetupConfigSnapshot>, // includes chain asset metadata and service policies
markets: Market[],
reader: ProtocolReader,
dexAgg: IDexAgg,
global_milestone: MilestoneResponse | null
}>setupChain() still publishes a single active setup for compatibility helpers
such as getActiveUserMarkets() and snapshot calls without explicit markets.
Multichain-safe code should pass explicit markets, reader, provider,
account, or setup context instead of relying on the latest singleton.
Architecture contract
The current SDK architecture is result-bound. A setupChain(...) result owns
the chain context that produced it, and downstream objects should keep using
that context even if another chain boots later.
| Layer | Contract |
|---|---|
| Setup snapshot | setupConfigSnapshot contains chain id, environment, cloned/frozen asset metadata, cloned/frozen external service policy, contract addresses, read transport, signer/account, API URL, and fee policy. |
| Returned markets | Market and CToken instances keep the setup snapshot and reader they were created with. Explicit returned-result calls stay on that chain after the module singleton moves. |
| Market listing | setupChain() returns only markets the SDK has deployment metadata for (display name + plugin addresses). An on-chain market the SDK does not yet list — e.g. one deployed or registered ahead of an SDK release — is skipped (with a console.warn) in every environment instead of failing the whole boot, so an unlisted/upcoming market is simply omitted until a later SDK release adds it. Plugin addresses are not on-chain-discoverable, so an unlisted market cannot be operated through the SDK anyway. (Prior versions threw on production-mainnet, which blanked the entire load.) |
| Compatibility globals | setup_config and all_markets exist for single-active-chain consumers. Treat no-argument helpers as compatibility paths, not multichain-safe state. |
| External services | Curvance API reward/native-yield slugs and Kyber API/router/chain aliases live in chain config and are cloned into the setup snapshot. Helper code should read the snapshot, not mutable exported config. |
| DEX routing | Markets receive a setup-bound dexAgg after boot. CToken route discovery and zap/leverage execution do not fall back to chain_config.dexAgg; unsupported or manually constructed markets fail closed unless a market-bound adapter is attached. |
| Fee/checker policy | Kyber-backed chains validate checker-compatible fee policy during setup. The current checker requires CURVANCE_FEE_BPS and the setup-resolved DAO receiver before routes are advertised or markets finish booting. |
RPC routing
- Wallet connected (signer with a
.provider) → the wallet's own provider is the primary read source; the chain's configured RPC + fallbacks absorb wallet RPC failures. This distributes read load across users' wallet RPCs and respects whichever endpoint each user chose. - Signerless / public view → the chain's configured RPC is primary; chain fallbacks serve as backup.
- Explicit
options.readProvider→ wins over all of the above. Use when you want deterministic read transport (e.g. fork testing). - Writes always route through the connected signer; they never use the chain RPC or fallbacks.
Write approvals
- High-level write methods always preflight the approvals they need before submit.
- Missing ERC20 allowance throws a descriptive error instead of sending a revert-prone transaction.
- Missing zapper or position-manager delegate approval also throws before submit.
- There is no setup-time approval mode switch. Use
approveUnderlying,approveZapAsset, andapprovePluginexplicitly when the caller needs to satisfy approvals.
Explore markets
for (const market of markets) {
console.log(`${market.name} | deposits: ${market.totalDeposits} | debt: ${market.totalDebt}`);
for (const token of market.tokens) {
console.log(` ${token.symbol} | price: ${token.getPrice(true)} | apy: ${token.getApy(true)}%`);
}
}❯ Providers
curvance_provider accepts any ethers v6 provider or signer. All providers are automatically wrapped with retry logic (exponential backoff for rate limits and 5xx errors).
| Type | Use case |
|---|---|
| ethers.Wallet | CLI / server-side with private key |
| ethers.JsonRpcSigner | Browser wallet (MetaMask, etc.) |
| ethers.JsonRpcProvider | Read-only or custom RPC |
| null | SDK constructs a provider from chain config |
curvance_signer = JsonRpcSigner | Wallet, required for write operations (deposit, borrow, etc.)
❯ Markets
Market is the top-level container. Each market groups related collateral and borrow tokens and tracks the user's aggregate position.
Market properties
market.name // market name
market.address // market contract address
market.totalDeposits // total market deposits (USD, Decimal)
market.totalDebt // total outstanding debt (USD, Decimal)
market.totalCollateral // total posted collateral (USD, Decimal)
market.cooldownLength // hold period between actions (20 min)
market.hasBorrowing() // whether this market supports borrowing
market.highestApy() // best supply APY across all tokens
market.ltv // LTV range {min, max} or single valueUser position (all in USD as Decimal)
market.userDeposits // total deposits
market.userDebt // total outstanding debt
market.userMaxDebt // maximum allowable debt
market.userRemainingCredit // available borrow capacity (with 0.1% buffer)
market.userCollateral // posted collateral (in shares)
market.positionHealth // health factor; null means infinite (no debt)
market.userNet // deposits - debtRate tracking
// rateType: 'day' | 'week' | 'month' | 'year'
market.getUserDepositsChange('week') // projected earnings
market.getUserDebtChange('week') // projected interest cost
market.getUserNetChange('week') // net projected changeData refresh
await market.reloadMarketData() // refresh rates, prices, utilization
await market.reloadUserData(account) // refresh user balances and position❯ Tokens (CToken / BorrowableCToken)
Tokens within a market are either CToken (collateral/supply side) or BorrowableCToken (extends CToken with borrow/repay). Access them via market.tokens or market.getBorrowableCTokens().
Token metadata
token.symbol
token.name
token.decimals
token.asset // underlying ERC20 address
token.isBorrowable // whether this token can be borrowed
token.isVault // whether underlying is an ERC4626 vault
token.isNativeVault // native token vault (e.g. shMON)
token.canZap // supports zap deposits
token.canLeverage // supports leverage
token.maxLeverage // max allowed leverage (Decimal)Market state
token.exchangeRate // current share-to-asset rate
token.totalAssets // total assets held (bigint)
token.totalSupply // total shares outstanding (bigint)
token.borrowPaused
token.collateralizationPaused
token.mintPausedPrices & conversions
token.getPrice() // share price (USD, Decimal)
token.getPrice(true) // asset price
token.convertTokensToUsd(amount) // TokenInput → USD
token.convertUsdToTokens(usd) // USD → TokenInput
token.convertTokenInputToShares(amount) // user input → shares
token.virtualConvertToAssets(shares) // shares → assets (cached, no RPC)
token.virtualConvertToShares(assets) // assets → shares (cached, no RPC)User balances
token.getUserShareBalance(inUSD) // cToken balance
token.getUserAssetBalance(inUSD) // underlying asset balance
token.getUserUnderlyingBalance(inUSD) // underlying token balance
token.getUserCollateral(inUSD) // posted collateral
token.getUserDebt(inUSD) // outstanding debt (borrow tokens)Market totals & caps
token.getDeposits(inUSD) // underlying assets held (USD or bigint)
token.getTotalCollateral(inUSD)
token.getCollateralCap(inUSD) // remaining collateral capacity
token.getDebtCap(inUSD) // remaining debt capacity
token.getRemainingCollateral(formatted)
token.getRemainingDebt(formatted)Collateral parameters
token.getCollRatio(inBPS) // collateralization ratio
token.getCollReqSoft(inBPS) // soft liquidation threshold
token.getCollReqHard(inBPS) // hard liquidation threshold
token.getLiqIncBase(inBPS) // liquidation incentive base
token.getLiqIncMin(inBPS) // liquidation incentive min
token.getLiqIncMax(inBPS) // liquidation incentive max
token.liquidationPrice // oracle liquidation price (null = infinite)APY & rates
token.getApy(asPercentage) // supply APY
token.getTotalSupplyRate() // supply APY + incentives + native yield
token.getBorrowRate(inPercentage) // borrow APY
token.getTotalBorrowRate() // borrow APY minus incentive rewards
// BorrowableCToken only
token.getLiquidity(inUSD) // available liquidity to borrow
token.getUtilizationRate(inPercentage)
token.getPredictedBorrowRate(inPercentage)
token.getMaxBorrowable() // max amount given creditPosition snapshot & preview
token.getSnapshot(account) // position snapshot for an account
token.maxRedemption(inShares, bufferTime) // max redeemable amount
token.maxRemovableCollateral(inShares, bufferTime) // max posted collateral removable without violating health
token.simulateDeposit(amount) // preview deposit without executing
token.simulateDepositAsCollateral(amount)❯ Core Operations
All amounts are Decimal (human-readable token units) unless noted.
Approvals
await token.approveUnderlying(amount, target) // approve underlying asset spend
await token.approve(amount, spender) // approve cToken itself
await token.getAllowance(contract, underlying) // check allowanceDeposit & Withdraw
// Deposit as supplier (earns yield, cannot be used as collateral)
await token.deposit(amount, zap?, receiver?)
// Deposit as collateral (enables borrowing against it)
await token.depositAsCollateral(amount, zapInstructions?, receiver?)
// Withdraw
await token.redeem(amount) // by asset amount
await token.redeemShares(amount) // by share amount
await token.redeemCollateral(amount, receiver?, owner?)
// Manage posted collateral
await token.postCollateral(amount) // post unposted balance as collateral
await token.removeCollateralExact(amount) // exact collateral removal, capped to the safe removable max
await token.removeMaxCollateral() // remove the maximum valid posted collateralBorrow & Repay (BorrowableCToken only)
await borrowToken.borrow(amount, receiver?)
await borrowToken.repay(amount)
// Previews
const impact = await borrowToken.hypotheticalBorrowOf(amount) // on-chain health preview
await borrowToken.fetchDebt(inUSD)
await borrowToken.debtBalance(account)Interest rate model
await borrowToken.fetchBorrowRate()
await borrowToken.fetchSupplyRate()
await borrowToken.fetchUtilizationRate()
await borrowToken.fetchPredictedBorrowRate()
await borrowToken.fetchUtilizationRateChange(assets, direction)
borrowToken.borrowChange(amount, rateType) // interest accrual over time period❯ Plugins (Zappers & Position Managers)
Zapper and PositionManager contracts must be approved before first use.
// Check and approve a plugin
const approved = await token.isPluginApproved('simple', 'zapper')
if (!approved) await token.approvePlugin('simple', 'zapper')
// Plugin types
// ZapperTypes: 'none' | 'native-vault' | 'vault' | 'simple' | 'native-simple'
// PositionManagerTypes: 'native-vault' | 'simple' | 'vault'
// `leverageDown(...)` currently executes through the 'simple' position manager only.
const zapper = token.getZapper('simple')
const positionManager = token.getPositionManager('simple')Prefer token.getZapper(...) so the zapper carries the token's setup-bound DEX aggregator. Direct construction must use the same setup result as the CToken it will operate on:
import { Zapper } from "curvance"
const setup = await setupChain("monad-mainnet", wallet)
const token = setup.markets[0].tokens[0]
const zapperAddress = token.getPluginAddress("simple", "zapper")
if (zapperAddress == null) throw new Error("Simple zapper is not configured")
const zapper = new Zapper(
zapperAddress,
wallet,
"simple",
setup.setupConfigSnapshot,
setup.dexAgg,
)A direct Zapper built without the setup-bound adapter throws. A Zapper
from one setup result also refuses to build calldata for a CToken from another
setup result.
❯ Zapping (Swap + Deposit)
Zap deposits allow depositing another token by swapping to the required underlying through the setup-bound DEX aggregator.
token.getDepositTokens(search?) is the route-discovery entrypoint. It always
includes the direct deposit asset and then adds native, vault, and simple-swap
routes only when the token and chain can execute them. DEX-sourced simple
routes require a market-bound executable adapter; unsupported DEX chains expose
readable markets but no simple zap or simple leverage routes.
// Native token (MON) → deposit
await token.approvePlugin('native-simple', 'zapper')
await token.depositAsCollateral(amount, 'native-simple')
// Any ERC20 → swap → deposit
await token.approvePlugin('simple', 'zapper')
const simpleZap = {
type: 'simple',
inputToken: inputTokenAddress,
slippage: new Decimal(0.01) // 1%
} as const
await token.approveZapAsset(simpleZap, amount)
await token.depositAsCollateral(amount, simpleZap)Check approval status for a zap before executing:
const rawZapAmount = toBigInt(amount, inputTokenDecimals)
const approved = await token.isZapAssetApproved(instructions, rawZapAmount)
if (!approved) await token.approveZapAsset(instructions, amount)❯ Leverage & Deleverage
Leverage uses the PositionManager plugin to atomically borrow and swap into the collateral token.
// One-step: deposit collateral + leverage
const positionManager = collateralToken.getPluginAddress('simple', 'positionManager')
if (positionManager == null) throw new Error("Simple position manager is not configured")
await collateralToken.approveUnderlying(amount, positionManager)
await collateralToken.depositAndLeverage(amount, borrowToken, targetLeverage, 'simple', slippage)
// Separate: deposit first, then leverage
await collateralToken.approveUnderlying(amount)
await collateralToken.depositAsCollateral(amount)
await collateralToken.leverageUp(borrowToken, new Decimal(3), 'simple', new Decimal(0.005))
// Reduce leverage
// Deleverage currently executes through the simple position manager path.
await collateralToken.leverageDown(borrowToken, currentLeverage, targetLeverage, 'simple', slippage)
// Check current leverage
collateralToken.getLeverage() // Decimal | null (null if no debt)Leverage previews (via ProtocolReader)
const preview = await reader.hypotheticalLeverageOf(account, depositCToken, borrowCToken, depositAmount)
// Returns: { currentLeverage, adjustMaxLeverage, maxLeverage, maxDebtBorrowable }❯ Health & Position Previews
Preview position health before executing any action. Returns a Decimal percentage (0–1) or null (infinite / no debt).
// Individual action previews
await market.previewPositionHealthDeposit(ctoken, amount)
await market.previewPositionHealthRedeem(ctoken, amount)
await market.previewPositionHealthBorrow(borrowToken, amount)
await market.previewPositionHealthRepay(borrowToken, amount)
await market.previewPositionHealthLeverageUp(depositCToken, borrowCToken, newLeverage, depositAmount)
await market.previewPositionHealthLeverageDown(depositCToken, borrowCToken, newLeverage, currentLeverage)
// Generic preview
await market.previewPositionHealth(depositCToken, borrowCToken, isDeposit, collateralAmt, isRepay, debtAmt, bufferTime)
// Projected earnings/cost impact
await market.previewAssetImpact(user, collateralCToken, debtCToken, depositAmount, borrowAmount, rateType)const health = await market.previewPositionHealthBorrow(borrowToken, new Decimal(1000))
if (health === null) {
// remains solvent with infinite health
} else if (health.lt(0.1)) {
console.warn("Would drop to 10% health - too risky")
}❯ Cooldowns
Curvance enforces a 20-minute hold period between certain actions.
market.cooldown // Date | null (current cooldown expiry)
await market.expiresAt(account) // fetch cooldown expiry from chain
await market.multiHoldExpiresAt(markets) // cooldown across multiple markets❯ Format Utilities
Pure calculation helpers for building UI or simulating outcomes. Amount and
leverage helpers primarily use Decimal; validation, health-status, slippage,
and normalization helpers also expose number, bigint, string, and structured
result types where those are the safer UI boundary.
Leverage math
import { leverage } from "curvance"
leverage.calculateBorrowAmount(depositUsd, leverageMultiplier)
leverage.calculateLeverageRatio(totalValue, debtAmount)
leverage.calculateDeleverageAmount(currentLeverage, targetLeverage, totalValue)
leverage.calculatePositionSize(tokenAmount, leverageMultiplier)
leverage.validateLeverageInput(input) // checks balance, min deposit, max leverage, liquidity
leverage.checkLeverageAmountBelowMinimum(input) // $10.10 minimum borrow
leverage.checkBorrowExceedsLiquidity(borrowAmount, availableLiquidity)Contract-level slippage amplification
import { amplifyContractSlippage, toContractSwapSlippage } from "curvance"
// Used internally by CToken.leverageUp / leverageDown / depositAndLeverage to
// expand the contract-level slippage budget for the equity-fraction amplification
// that on-chain `checkSlippage` applies. Each deterministic per-swap loss
// (CURVANCE_FEE_BPS, full-deleverage overshoot) gets amplified by (L-1) in
// (L-1)-terms, so contractSlippage must absorb it without dipping into the
// user's raw `slippage` budget (reserved for variable DEX impact + drift).
amplifyContractSlippage(baseSlippageBps, leverageDelta, bpsToAmplify)
// Used by DEX aggregator adapters in `quoteAction` to
// compute the WAD-BPS slippage tolerance for the `Swap.slippage` struct
// field consumed by on-chain `_swapSafe`. When an adapter's fee model is
// represented as value loss in the swap calldata (for example Kyber's
// currency_in fee), pass `feeBps` so `_swapSafe`
// does not treat deterministic fee loss as user slippage. Adapters whose
// fees are not observable as swap value loss should omit `feeBps` / pass 0n.
toContractSwapSlippage(userSlippageBps, feeBps?)Borrow math
import { borrow } from "curvance"
borrow.calculateMaxBorrow(remainingCredit, remainingDebt, availableLiquidity)
borrow.calculateMaxRepay(userBalance, userDebt)
borrow.validateRepayRemainder(currentDebtUsd, repayAmountUsd) // enforces $10 minimum remainder
borrow.calculateDebtPreview(currentDebt, amount, isRepaying)
borrow.convertAmountByCurrencyView(amount, price, currencyView) // USD ↔ token viewCollateral math
import { collateral } from "curvance"
collateral.calculateExchangeRate(assetBalance, shareBalance)
collateral.calculateCollateralBreakdown(assetBalance, shares, exchangeRate)
collateral.calculateNewCollateral(currentCollateral, amount, action)Health display
import { health } from "curvance"
health.getHealthStatus(percentageValue) // 'Danger' | 'Caution' | 'Healthy'
health.healthFactorToPercentage(rawFactor)
health.formatHealthFactorPercentage(value)
health.formatHealthFactor(value) // handles infinity
health.getLiquidityStatus(ratio) // 'green' | 'yellow' | 'red'Amount formatting
import { amounts } from "curvance"
amounts.clampUsdDustAmount(value) // zero out sub-$0.01 amounts
amounts.normalizeAmountString(value, maxFractionDigits, roundingMode)
amounts.normalizeCurrencyAmounts({ amount, currencyView, tokenDecimals, price })❯ Helpers & Utilities
import {
getContractAddresses,
contractSetup,
toDecimal, toBigInt,
getDepositApy, getBorrowCost,
getInterestYield, getNativeYield,
getMerklDepositIncentives, getMerklBorrowIncentives,
getRateSeconds,
WAD, WAD_DECIMAL, BPS, RAY,
UINT256_MAX, EMPTY_ADDRESS, NATIVE_ADDRESS,
DEFAULT_SLIPPAGE_BPS,
} from "curvance"| Helper | Description |
|---|---|
| getContractAddresses(chain) | All contract addresses for a chain |
| contractSetup(provider, address, abi) | Create a typed contract instance |
| toDecimal(value, decimals) | bigint → Decimal |
| toBigInt(value, decimals) | Decimal → bigint |
| getDepositApy(token, opportunities, apyOverrides) | Total deposit yield (interest + Merkl + native) |
| getBorrowCost(token, opportunities) | Net borrow cost; may be negative when rewards exceed rate |
| getInterestYield(token) | Lending APY only |
| getNativeYield(token, apyOverrides) | Native yield component |
| getMerklDepositIncentives(tokenAddress, opportunities) | Merkl reward APR for deposits |
| getMerklBorrowIncentives(tokenAddress, opportunities) | Merkl reward APR for borrows |
| getRateSeconds(rateType) | Convert 'year' \| 'month' \| 'week' \| 'day' → seconds |
❯ Fee Policy
The SDK supports configurable fees applied at the DEX aggregator layer for swaps. Fees are denominated in BPS of the swap input and charged on leverage, deleverage, deposit+leverage, and zap operations.
For standard Curvance app/front-end usage, omit options.feePolicy and let
setupChain() build the default Curvance fee policy:
const { markets } = await setupChain("monad-mainnet", wallet)The default policy charges CURVANCE_FEE_BPS and resolves the fee receiver from
CentralRegistry.daoAddress() once during setup. App consumers should not
hardcode or pin a DAO fee receiver locally. Pass options.feePolicy only for an
intentional custom integration override.
import {
CURVANCE_FEE_BPS,
flatFeePolicy,
setupChain,
} from "curvance"
const defaultSetup = await setupChain("monad-mainnet", wallet)
const feePolicy = flatFeePolicy({
// Kyber-backed chains require one exact checker-compatible DEX fee.
bps: CURVANCE_FEE_BPS,
feeReceiver: defaultSetup.setupConfigSnapshot.feePolicy.feeReceiver,
chain: "monad-mainnet",
})
const { markets } = await setupChain("monad-mainnet", wallet, undefined, { feePolicy })On Kyber-backed chains, setup validates explicit policies before rewards or
markets boot. A zero-fee policy, wrong BPS value, or wrong receiver rejects
with a checker-policy error. Context-dependent lower tiers such as
stableToStableBps are not valid for checker-bound Kyber routes because the
on-chain checker enforces one exact BPS value and DAO receiver. On
unsupported-Dex chains such as arb-sepolia, NO_FEE_POLICY remains valid and
setup skips the DAO lookup because no DEX route can execute there.
The SDK automatically returns 0 bps for native ↔ wrapped-native swaps and same-token no-op zaps.
// FeePolicy interface: implement your own
interface FeePolicy {
// "any" marks chain-agnostic no-op policies; chain-bound policies must match setupChain.
chain?: "monad-mainnet" | "arb-sepolia" | "any";
feeReceiver: address;
// Required on checker-bound routes when the policy is custom.
checkerCompatibility?: {
exactFeeBpsForDexSwaps: bigint;
feeReceiver: address;
};
getFeeBps(ctx: FeePolicyContext): bigint;
}
// Context passed to getFeeBps
interface FeePolicyContext {
operation: 'leverage-up' | 'leverage-down' | 'deposit-and-leverage' | 'zap';
inputToken: address;
outputToken: address;
inputAmount: bigint;
currentLeverage: Decimal | null;
targetLeverage: Decimal | null;
}❯ Integrations
Merkl rewards
import { fetchMerklOpportunities, fetchMerklUserRewards, fetchMerklCampaignsBySymbol } from "curvance"
// Active opportunities for a production display path (APR, token, type)
const opportunities = await fetchMerklOpportunities({ chainId: 143 })
// Pending rewards for a user
const rewards = await fetchMerklUserRewards({ wallet: address, chainId: 143 })
// Campaigns for a specific token on one chain
const campaigns = await fetchMerklCampaignsBySymbol({ tokenSymbol: "USDC", chainId: 143 })
// Chainless Merkl calls are all-chain utilities. Filter explicitly before
// using them in production multichain display paths.
const allChainOpportunities = await fetchMerklOpportunities({})Portfolio snapshots
import { takePortfolioSnapshot, snapshotMarket } from "curvance"
// Full portfolio across the current active-chain markets
const snapshot = await takePortfolioSnapshot(account)
// Returns: { account, chain, timestamp, totalDepositsUSD, totalDebtUSD, netUSD, dailyEarnings, dailyCost, markets[] }
// Each market row includes { chain, chainId }. Mixed-chain snapshots require:
// takePortfolioSnapshot(account, { markets, allowMixedChains: true })
// Single market
const marketSnapshot = snapshotMarket(market)snapshotMarket(...) requires full user token data. takePortfolioSnapshot(...) will automatically promote summary-scoped markets back to full user data before reading token balances. If you previously called refreshActiveUserMarketSummaries(...) and need a direct single-market snapshot, run market.reloadUserData(account) or Market.reloadUserMarkets(...) first.
❯ Optimizer
The OptimizerReader reads yield-rebalancing vaults that allocate across markets. Reader calls that accrue optimizer state are simulated with staticCall by the SDK.
import Decimal from "decimal.js"
import { ERC20, LendingOptimizer, OptimizerReader, setupChain } from "curvance"
const optimizerReader = new OptimizerReader(optimizerReaderAddress, provider)
await optimizerReader.getOptimizerAPY(optimizerAddress)
// Returns: weighted-average optimizer APY in WAD
await optimizerReader.getOptimizerMarketData(optimizerAddresses)
// Returns: { totalAssets, sharePrice, performanceFee, apy, markets: [{ address, allocatedAssets, liquidity, allocationCap, allocationCapUtilizationBps }] }
await optimizerReader.getOptimizerUserData(optimizerAddresses, account)
// Returns: user balance and redeemable amounts
await optimizerReader.optimalRebalance(optimizerAddress, 100n)
// Returns: { actions: { cToken, assetsOrBps }[], bounds: { cToken, minBps, maxBps }[] }
await optimizerReader.isBad(optimizerAddress)
// Returns bad cToken markets for the optimizer
const asset = new ERC20(provider, assetAddress, undefined, undefined, signer)
const setup = await setupChain("monad-mainnet", signer)
const vault = new LendingOptimizer(optimizerAddress, asset, provider, signer, {
setup: setup.setupConfigSnapshot,
dexAgg: setup.dexAgg,
})
await vault.deposit(amount, account)
await vault.withdraw(amount, account, account)
await vault.redeem(shares, account, account)
const zap = {
type: "optimizer",
inputToken: usdcAddress,
slippage: new Decimal("0.01"),
} as const
await vault.approveZapAsset(zap, amount)
await vault.deposit(amount, zap, account)❯ TypeScript Types
// Primitives
type address = `0x${string}` // checksummed Ethereum address
type bytes = `0x${string}` // hex-encoded calldata
type Percentage = Decimal // 0–1, e.g. 0.7 = 70%
type USD = Decimal // human-readable USD (1.0 = $1)
type USD_WAD = bigint // USD in 1e18 WAD format
type TokenInput = Decimal // human-readable token amount
type TypeBPS = bigint // basis points (10000 = 100%)
type ChainRpcPrefix = "monad-mainnet" | "arb-sepolia"
type ChainEnvironment = "production-mainnet" | "testnet" | "local"
type curvance_read_provider = JsonRpcProvider
type curvance_provider = JsonRpcSigner | Wallet | JsonRpcProvider
type curvance_signer = JsonRpcSigner | Wallet
interface SetupConfigSnapshot {
chain: ChainRpcPrefix
chainId: number
environment: ChainEnvironment
assets: Readonly<ChainAssetConfig>
services: Readonly<ChainServiceConfig>
contracts: Readonly<Record<string, unknown>>
readProvider: curvance_read_provider
signer: curvance_signer | null
account: address | null
api_url: string
feePolicy: FeePolicy
}
// Market categorization
type MarketCategory = "stablecoin" | "staking" | "restaking" | "yield-stablecoin" | "blue-chip" | "native"
type CollateralSource = "Renzo" | "Upshift" | "Yuzu" | "Native" | "Circle" | "Fastlane" | "Apriori" | "Mu Digital" | "Kintsu" | "Reservoir"
// Operations
type ZapperTypes = 'none' | 'native-vault' | 'vault' | 'simple' | 'native-simple'
type PositionManagerTypes = 'native-vault' | 'simple' | 'vault'
// `leverageDown(...)` accepts 'simple' only.
type ChangeRate = 'year' | 'month' | 'week' | 'day'
// DEX
interface Quote {
to: address
calldata: bytes
min_out: bigint
out: bigint
}Core monetary, token, share, health, APY, and fixed-point values use bigint
or Decimal. Backend API DTOs may expose raw number fields before SDK
normalization; do not use those DTO fields as contract-scale values.
❯ Constants
WAD // 1_000_000_000_000_000_000n (1e18)
BPS // 10_000n
RAY // 1_000_000_000_000_000_000_000_000_000n (1e27)
WAD_SQUARED // 1e36n
WAD_DECIMAL // Decimal('1e18')
UINT256_MAX
EMPTY_ADDRESS // '0x0000000000000000000000000000000000000000'
NATIVE_ADDRESS // canonical native token address
DEFAULT_SLIPPAGE_BPS // 100n (1%)Leverage tuning (LEVERAGE)
Exposed tuning block used by leverage preview / mutation paths. Values are considered tunable across releases. SDK consumers pinning against specific values opt into the coupling.
import { LEVERAGE } from 'curvance';
LEVERAGE.MAX_LEVERAGE_FACTOR // Decimal(0.98)
// Cap applied to the theoretical max leverage span. Reserves ~2% of the
// equity-fraction slippage budget for deterministic loss channels
// (CURVANCE_FEE_BPS, pool-fee variance, oracle drift, share rounding)
// that would otherwise push post-op LTV above collRatio at the boundary.
LEVERAGE.LEVERAGE_UP_BUFFER_BPS // 10n
// Flat BPS buffer added to leverage-up slippage for share-rounding + fresh
// Oracle price drift between snapshot RPC and tx broadcast. NOT amplified
// by (L-1); the contract's equity-fraction denominator handles amplification.
LEVERAGE.DELEVERAGE_OVERHEAD_BPS // 60n
// BPS overhead added to full-deleverage swap sizing to absorb DEX impact
// and oracle drift without leaving dust debt. The contract returns any
// excess debt token to the user, so economic loss is zero, but
// `checkSlippage` treats the intentional overshoot as equity loss and
// amplifies it by (L-1), which the contract-slippage expansion compensates.
LEVERAGE.SHARES_BUFFER_BPS // 2n
// Downward BPS buffer on `virtualConvertToShares` and the inner
// `previewDeposit` step of `getVaultExpectedShares`. Covers exchange-rate
// drift from interest accrual since cache load so actual mint satisfies
// `shares >= expectedShares` at tx inclusion.
LEVERAGE.LEVERAGE_UP_VAULT_DRIFT_BPS // 30n
// Per-leverage-unit BPS buffer for `checkSlippage` on vault + native-vault
// leverage-up paths. Absorbs drift between the collateral vault's
// fundamental mint rate at tx time and the stored oracle price that
// `marketManager.statusOf` uses inside `checkSlippage`. Mirrors the
// `(L-1) × feeBps` amplification the simple branch uses for DEX-fee
// absorption, with a different K since vault paths have no DEX leg.❯ Dependencies
| Package | Purpose | |---|---| | ethers v6 | Typed contract interactions, providers, and signer handling | | decimal.js | Arbitrary-precision math for all token amounts, prices, and rates |
❯ SDK Pre-Publish Checklist
Run before every SDK npm publish:
Typecheck, build, and deterministic transport gate green.
node node_modules/typescript/bin/tsc --noEmit npm run build npm run test:transportnpm testis an alias fortest:transport.tests/rpc-config-shape.test.tslocks the structural invariants ofchain_rpc_config(no known-bad RPCs, no duplicate fallbacks, policy fields within sane ranges).Fork gate green, or explicitly classified as pending.
npm run test:forkis the live fork/write gate. If it skips becauseTEST_RPC, deployer keys, or a generated fixture are missing, the SDK can be called deterministic/package covered, but not fork-covered.Package artifact smoke green.
npm run test:dist-smoke npm pack --dry-run --jsonprepackandprepublishOnlyrebuilddist, andtest:dist-smokeimports the packed package root. Package consumers load the artifact, so source-green or build-green alone is not package-boundary proof. The dry-run package should containREADME.md,package.json, anddist/**; source files and tests should not be published.Workspace hygiene clean.
git diff --check git status --shortConfirm new imported production files are tracked. This matters because dirty-tree tests can pass while a clean package checkout cannot import an untracked source file.
SDK gate interpretation
test:transport,test:all,test:dist-smoke,npm pack --dry-run --json, andgit diff --checkgreen means the SDK is deterministic/package covered.test:forkmust execute against a local Anvil-compatible fork before calling the SDK fork-covered. A command that exits 0 after skip messages is not live fork proof.- App build, app Cypress/Vitest, and app RPC-origin probes are downstream adoption checks. Run them after publishing or linking the packed SDK into the app repo; they are not part of the SDK-only publish gate.
Post-Publish App Rollout Checks
After publishing or linking a packed SDK artifact into the app repo:
For RPC-adjacent SDK changes, run the app-origin RPC probe.
cd path/to/curvance-app RPC_PROBE_YES=1 node scripts/rpc-probe.mjsThe probe hits
staging.curvance.comandapp.curvance.comorigins against every URL inchain_rpc_config. Required thresholds for the primary and first fallback on each chain:- CORS preflight returns
204or200with a matchingAccess-Control-Allow-Originheader - Correctness call returns a valid
chainIdmatching the chain - Latency
p95 ≤ 500ms - 50-concurrent-burst:
≥ 45/50successful (allow 10% for transient blips)
Any primary or first-fallback failing these thresholds blocks the publish. Demote to a later fallback position or replace.
Deeper-cascade fallbacks (
fallbacks[1]+) MAY have looser limits if documented inline with a comment inchain_rpc_config.- CORS preflight returns
Do not add the probe to CI. The probe fires ~500 requests per run across 5-10 public RPCs from a single IP. Running it on every PR would trip per-IP rate limits and eventually provoke origin bans from the free RPCs we depend on. That recreates the exact failure mode (monadinfra 403'ing
staging.curvance.com) that motivated this probe.App rollout workflow. Version bump ->
npm publish-> in app repo, bumpcurvanceinpackage.jsonto the new version ->yarn install-> commityarn.lock-> deploy.
