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 Data API returns a per-vault integration manifest with the exact addresses, methods, approvals, and gating for each deposit/withdraw path — then you only bring the transaction. This page is the underlying ground truth.

The contracts#

Each CL vault is two contracts plus shared singletons:

ContractRole
Vault (ERC-20 share token)Holds your position; deposit/withdraw with both pool tokens. 18 decimals.
StrategyOwns 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 / GaugeThe 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:

bash
curl "https://vaults.topazdex.com/api/v1/vaults/sol-wbnb-mid?include=abi"

Reads#

All read-only; no indexer needed. Key vault reads:

CallReturns
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):

CallReturns / 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):

derived-reads.ts
// 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#

ActionApproveSpender
Direct deposittoken0 and token1Vault
Direct withdraw— (burns your shares)
Zap in (token / both)the token(s) you sendZapper
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.

ts
// 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#

ts
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

A zap into a non-empty vault mints fewer shares than 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.
ts
// 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.

ts
// 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

Set 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#

  1. isCalm() is true (and globalPause / paused are false).
  2. Approve (token0+token1 → Vault for direct; token → Zapper for zap).
  3. Static-call / previewDeposit to get the exact shares out.
  4. minShares = out × (1 − slippage).
  5. 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:

SelectorErrorMeaning → fix
0xfa6ad355TooMuchSlippageShares minted < your minShares — the #1 cause of deposit/zap reverts. Simulate to get exact out, don't estimate naively.
0x26c87876NotCalmPool not calm (TWAP deviation). Gate deposits/zap-ins on isCalm(). Withdraws aren't gated.
0x850c6f76SlippageTooHighThe zapper's internal swap exceeded its slippage. Retry or use a smaller size.
0xeeb4f612VaultNotRegisteredWrong vault address passed to the zapper.
0x15fa6e71NotPoolTokenzapInToken called with a token that isn't a pool leg. Use token0/token1, or BNB/both.
0x1f2a2005ZeroAmountAmount or value is 0.
0xb317087b / 0x22bbb43cNoShares / NotEnoughTokensDegenerate 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#

ContractEventUse
VaultDeposit(user, shares, amount0, amount1, fee0, fee1)Confirm deposits, user history
VaultWithdraw(user, shares, amount0, amount1)Confirm withdraws
StrategyHarvest(compounded, perfFee, callFee)Compounding history, APY reconstruction
StrategyTVL(bal0, bal1)TVL / share-price time series
ZapperZappedIn(vault, receiver, sharesOut)Zap-in confirmations
ZapperZappedOut(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}.

KeySymbolKindVault address
sol-wbnb-midtclSOL-WBNB-MConcentrated Liquidity0x4C8A9bda546b48f73d47E0c5C630AEEC222D61Ba
usdt-wbnb-midtclUSDT-WBNB-MConcentrated Liquidity0x97DDAa9033Cc025aA11b778a02f039Db2161f969
btcb-wbnb-midtclBTCB-WBNB-MConcentrated Liquidity0xB9b4281Cc118F96Dc9fBF1e6b8aE4690a95537fc
eth-wbnb-midtclETH-WBNB-MConcentrated Liquidity0xA2AA979E014d256229c0CbC82739678dB6AB0659
usdt-btcb-midtclUSDT-BTCB-MConcentrated Liquidity0xF87b9cF93d1c54d790bfF21fE85fAa5929329a33
eth-usdt-midtclETH-USDT-MConcentrated Liquidity0x76EeC8C0C49F6e5708b45900124019105756EBfD
ub-usdt-midtclUB-USDT-MConcentrated Liquidity0xCe01B7c86aDa2E8582ae0447237C7f091D36CfB8
usdt-sol-midtclUSDT-SOL-MConcentrated Liquidity0x159cDE3FfdB1291D89dd62B088D67F01dE583795
xrp-usdt-midtclXRP-USDT-MConcentrated Liquidity0x60053E86673ef59796322836740FDE36F999f2da
zec-usdt-midtclZEC-USDT-MConcentrated Liquidity0x21666D9320152baf100dF0d73d288B4899b2Ad9B
topaz-wbnb-v2tvTOPAZ-WBNBV2 LP0xa66Bd718C45AaB17922Cf13C620eE21B7d156705
topaz-usdt-v2tvTOPAZ-USDTV2 LP0xf2126141E65527A2581113cF4b9fF1343ccBafFA

Keep reading#

Data APISubgraphHow the strategy works