Developers
Smart contracts
Read vault state and send deposits, withdrawals, and zaps directly against the contracts. Examples are shown in both viem and ethers; the rules below are what keep transactions from reverting.
Prefer not to manage RPC + math yourself?
The contracts#
Each CL vault is two contracts plus shared singletons:
| Contract | Role |
|---|---|
| Vault (ERC-20 share token) | Holds your position; deposit/withdraw with both pool tokens. 18 decimals. |
| Strategy | Owns the liquidity NFT, harvests, rebalances; exposes price/tick/range and fees. |
| Zapper (shared) | Single-token / both / BNB entry & exit. |
| Strategy Factory (shared) | Keeper, fee recipient, global pause. |
| Pool / Gauge | The underlying CL pool and its reward gauge. The pool is readable via strategy.pool(); the gauge is resolved from the Stats/Data API by pool. |
A CL vault's share token is a standard ERC-20 — it is not ERC-4626, and there is no single asset(); the underlying is two tokens (token0() / token1(), sorted by address). For V2 (ERC-4626) vaults, see below.
ABIs#
Ship the ABIs for TopazCLVault, TopazCLStrategy, TopazCLZapper, TopazCLStrategyFactory, plus a standard ERC-20, ICLGauge (earned), and ICLPool (slot0 / liquidity). Or fetch full JSON ABIs per vault from the Data API:
curl "https://vaults.topazdex.com/api/v1/vaults/sol-wbnb-mid?include=abi"Reads#
All read-only; no indexer needed. Key vault reads:
| Call | Returns |
|---|---|
token0() / token1() | pool tokens (sorted) |
strategy() | the strategy address |
totalSupply() / balanceOf(user) | total / user shares |
balances() | (amount0, amount1) total underlying held |
isCalm() | deposit / zap-in availability |
previewDeposit(a0, a1) | (shares, used0, used1, fee0, fee1) |
previewWithdraw(shares) | (amount0, amount1) |
Strategy reads (range & status):
| Call | Returns / notes |
|---|---|
price() | token0 priced in token1, scaled 1e36 |
currentTick() | pool tick |
positionMain() | (nftId, tickLower, tickUpper) — in range ⇔ tickLower ≤ tick ≤ tickUpper |
positionAlt() | alt range (may be degenerate) |
performanceFeeBps() / callFeeBps() / slippageBps() | fee + slippage config |
paused() / lastHarvest() | emergency state / last compound ts |
Derived values#
The values you'll actually display (viem; ethers maps 1:1):
// user's underlying token amounts
const supply = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "totalSupply" });
const [bal0, bal1] = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "balances" });
const userShares = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "balanceOf", args: [user] });
const frac = supply === 0n ? 0n : (userShares * 10n ** 18n) / supply; // 1e18-scaled
const userToken0 = (bal0 * frac) / 10n ** 18n;
const userToken1 = (bal1 * frac) / 10n ** 18n;
// TVL in token1 terms, then USD with token prices from the Stats/Data API
const price = await publicClient.readContract({ address: strategy, abi: strategyAbi, functionName: "price" }); // 1e36
const tvlInToken1 = bal1 + (bal0 * price) / 10n ** 36n;
// in-range?
const [, tickLower, tickUpper] = await publicClient.readContract({ address: strategy, abi: strategyAbi, functionName: "positionMain" });
const tick = await publicClient.readContract({ address: strategy, abi: strategyAbi, functionName: "currentTick" });
const inRange = tick >= tickLower && tick <= tickUpper;Writes#
Approvals#
| Action | Approve | Spender |
|---|---|---|
| Direct deposit | token0 and token1 | Vault |
| Direct withdraw | — (burns your shares) | — |
| Zap in (token / both) | the token(s) you send | Zapper |
| Zap in (BNB) | — (send value) | — |
| Zap out (any) | shares (vault.approve) | Zapper |
Direct deposit (both tokens)#
depositpulls the proper ratio and returns any leftover token, so inputs don't need to match the pool ratio. previewDeposit is an exact on-chain quote — derive minShares from it.
// approve token0 & token1 -> vault first
const calm = await publicClient.readContract({ address: vault, abi: vaultAbi, functionName: "isCalm" });
if (!calm) throw new Error("not calm — try again shortly");
const [shares] = await publicClient.readContract({
address: vault, abi: vaultAbi, functionName: "previewDeposit", args: [a0, a1],
});
const minShares = (shares * (10_000n - slippageBps)) / 10_000n; // e.g. 100 bps
const { request } = await publicClient.simulateContract({
account, address: vault, abi: vaultAbi, functionName: "deposit",
args: [a0, a1, minShares],
});
await walletClient.writeContract(request);Direct withdraw#
const [a0, a1] = await publicClient.readContract({
address: vault, abi: vaultAbi, functionName: "previewWithdraw", args: [shares],
});
const min0 = (a0 * (10_000n - slippageBps)) / 10_000n;
const min1 = (a1 * (10_000n - slippageBps)) / 10_000n;
const { request } = await publicClient.simulateContract({
account, address: vault, abi: vaultAbi, functionName: "withdraw",
args: [shares, min0, min1], // or "withdrawAll", [min0, min1]
});
await walletClient.writeContract(request);Zap in (single token / both / BNB)#
Simulate — don't estimate naively
inputValue ÷ pricePerShare because of the in-pool swap plusthe position's imbalance cost. Static-call the zap with minShares = 0 to get the exact out, then guard ~2% below it. A naive estimate reverts with TooMuchSlippage almost every time.// approve token -> zapper first (or send BNB as value, shown here)
// 1. simulate with minShares = 0 to read the exact sharesOut
const { result: expected } = await publicClient.simulateContract({
account, address: zapper, abi: zapperAbi, functionName: "zapInBNB",
args: [vault, account, 0n], value,
});
// 2. guard ~2% below
const minShares = (expected * 9_800n) / 10_000n;
// 3. estimate gas and double it
const gas = await publicClient.estimateContractGas({
account, address: zapper, abi: zapperAbi, functionName: "zapInBNB",
args: [vault, account, minShares], value,
});
const { request } = await publicClient.simulateContract({
account, address: zapper, abi: zapperAbi, functionName: "zapInBNB",
args: [vault, account, minShares], value, gas: gas * 2n,
});
await walletClient.writeContract(request);
// zapInToken(vault, token, amount, receiver, minShares) and
// zapInBoth(vault, a0, a1, receiver, minShares) follow the same pattern.Zap out (both / single token / BNB)#
Approve shares to the zapper, then simulate the same way to get the exact amount out and guard below it.
// approve shares -> zapper first: vault.approve(zapper, shares)
const { result: out } = await publicClient.simulateContract({
account, address: zapper, abi: zapperAbi, functionName: "zapOutBNB",
args: [vault, shares, account, 0n],
});
const minBNB = (out * (10_000n - userSlippageBps)) / 10_000n;
const gas = await publicClient.estimateContractGas({
account, address: zapper, abi: zapperAbi, functionName: "zapOutBNB",
args: [vault, shares, account, minBNB],
});
const { request } = await publicClient.simulateContract({
account, address: zapper, abi: zapperAbi, functionName: "zapOutBNB",
args: [vault, shares, account, minBNB], gas: gas * 2n,
});
await walletClient.writeContract(request);
// zapOutBoth(vault, shares, receiver, min0, min1) and
// zapOutToken(vault, token, shares, receiver, minOut) follow the same pattern.Always double the gas on zaps
gasLimit ≈ estimateGas × 2 (or a ~3M floor) on every zap, especially zapOut*: the pool's dynamic-fee module reads a TWAP on each swap and zap-out also unwinds the position, so a tight estimate can revert OutOfGas.The correct deposit / zap-in sequence#
isCalm()is true (andglobalPause/pausedare false).- Approve (token0+token1 → Vault for direct; token → Zapper for zap).
- Static-call /
previewDepositto get the exact shares out. minShares = out × (1 − slippage).- Send with
gasLimit = estimate × 2(zaps).
Troubleshooting reverts#
The contracts use custom errors (4-byte selector = error.data.slice(0, 10)). If withdraw works but deposit/zap-in reverts, it's almost always slippage or the calm gate:
| Selector | Error | Meaning → fix |
|---|---|---|
0xfa6ad355 | TooMuchSlippage | Shares minted < your minShares — the #1 cause of deposit/zap reverts. Simulate to get exact out, don't estimate naively. |
0x26c87876 | NotCalm | Pool not calm (TWAP deviation). Gate deposits/zap-ins on isCalm(). Withdraws aren't gated. |
0x850c6f76 | SlippageTooHigh | The zapper's internal swap exceeded its slippage. Retry or use a smaller size. |
0xeeb4f612 | VaultNotRegistered | Wrong vault address passed to the zapper. |
0x15fa6e71 | NotPoolToken | zapInToken called with a token that isn't a pool leg. Use token0/token1, or BNB/both. |
0x1f2a2005 | ZeroAmount | Amount or value is 0. |
0xb317087b / 0x22bbb43c | NoShares / NotEnoughTokens | Degenerate amounts — usually a too-small or zero-leg deposit. |
| string “…allowance…” | — | Missing approval: zap → approve token to the Zapper; direct deposit → approve token0 and token1 to the Vault. |
Events#
| Contract | Event | Use |
|---|---|---|
| Vault | Deposit(user, shares, amount0, amount1, fee0, fee1) | Confirm deposits, user history |
| Vault | Withdraw(user, shares, amount0, amount1) | Confirm withdraws |
| Strategy | Harvest(compounded, perfFee, callFee) | Compounding history, APY reconstruction |
| Strategy | TVL(bal0, bal1) | TVL / share-price time series |
| Zapper | ZappedIn(vault, receiver, sharesOut) | Zap-in confirmations |
| Zapper | ZappedOut(vault, receiver, sharesIn) | Zap-out confirmations |
For historical / derived data (APY, P&L, TVL history), prefer the subgraph over reconstructing from raw logs.
V2 (ERC-4626) vaults#
V2 vaults use a simpler, standard interface: asset() is the LP, with deposit / redeem / previewX / convertToShares, plus the shared V2 zapper for single-token and BNB entry/exit. There is no calm gate, no tick range, and no strategy contract. Shares are 24-dec (read decimals()). Direct deposit(assets, receiver, minShares) carries an on-chain slippage guard; direct redeem(...) has no minOut— for a slippage-protected single-asset exit, route through the V2 zapper's zapOut*.
Vault address reference#
Rendered from src/config/deployments/frontend.bsc.json — the source of truth. Strategy/pool/gauge for each CL vault are readable on-chain and via /api/v1/vaults/{key}.
| Key | Symbol | Kind | Vault address |
|---|---|---|---|
sol-wbnb-mid | tclSOL-WBNB-M | Concentrated Liquidity | 0x4C8A9bda546b48f73d47E0c5C630AEEC222D61Ba |
usdt-wbnb-mid | tclUSDT-WBNB-M | Concentrated Liquidity | 0x97DDAa9033Cc025aA11b778a02f039Db2161f969 |
btcb-wbnb-mid | tclBTCB-WBNB-M | Concentrated Liquidity | 0xB9b4281Cc118F96Dc9fBF1e6b8aE4690a95537fc |
eth-wbnb-mid | tclETH-WBNB-M | Concentrated Liquidity | 0xA2AA979E014d256229c0CbC82739678dB6AB0659 |
usdt-btcb-mid | tclUSDT-BTCB-M | Concentrated Liquidity | 0xF87b9cF93d1c54d790bfF21fE85fAa5929329a33 |
eth-usdt-mid | tclETH-USDT-M | Concentrated Liquidity | 0x76EeC8C0C49F6e5708b45900124019105756EBfD |
ub-usdt-mid | tclUB-USDT-M | Concentrated Liquidity | 0xCe01B7c86aDa2E8582ae0447237C7f091D36CfB8 |
usdt-sol-mid | tclUSDT-SOL-M | Concentrated Liquidity | 0x159cDE3FfdB1291D89dd62B088D67F01dE583795 |
xrp-usdt-mid | tclXRP-USDT-M | Concentrated Liquidity | 0x60053E86673ef59796322836740FDE36F999f2da |
zec-usdt-mid | tclZEC-USDT-M | Concentrated Liquidity | 0x21666D9320152baf100dF0d73d288B4899b2Ad9B |
topaz-wbnb-v2 | tvTOPAZ-WBNB | V2 LP | 0xa66Bd718C45AaB17922Cf13C620eE21B7d156705 |
topaz-usdt-v2 | tvTOPAZ-USDT | V2 LP | 0xf2126141E65527A2581113cF4b9fF1343ccBafFA |