Basic Operations

Signatures and call order for the four pool entry points: swap, deposit, withdraw, swapLiability. Everything here targets one Pool on one chain; choosing between pools is an off-chain decision covered in Quotes & Routing.


1. Interfaces

IPool.sol does not declare the trading functions. The shipped IPool carries the shared types (Asset, SwapQuote, OracleConfig, RiskConfig, FeeParams, OpType, HookSlot, DepositResult, WithdrawResult, RoutePath), the admin* and hook* gated entry points, and a set of views. swap, deposit, withdraw, withdrawTo, donate, swapLiability, getSwapQuote, previewWithdraw and getCoverageRatio are declared on Pool.sol only. IPool(pool).swap(...) does not compile.

Declare the surface you need. This block is complete and correct for everything on this page:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; interface IBtrPool { struct DepositResult { uint256 lpAmount; // shares minted, net of deadLp uint256 actualDeposit; // amount actually received (measured) uint256 deadLp; // shares sunk to address(0) if this deposit opened the leg } struct WithdrawResult { uint256 amountOut; uint256 lpBurned; } struct SwapQuote { uint256 amountOut; uint256 amountIn; uint16 spreadPbps; uint256 protoFee; uint256 lpFee; int8 skewIn; int8 skewOut; uint256 markPrice; // oracle fair value, WAD, tokenOut per tokenIn uint256 midPrice; // inventory-skewed centre the book quotes around uint256 covToll; // coverage toll withheld from gross output, tokenOut units address[] routeHops; uint256[] hopAmounts; uint256[] hopPrices; // populated by the view, empty on the exec path } function swap( address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, address recipient, uint256 deadline ) external payable returns (uint256 amountOut); function deposit(address token, uint256 amount) external payable returns (DepositResult memory); function withdraw(address token, uint256 lpAmount, uint256 minAmountOut, uint256 deadline) external returns (WithdrawResult memory); function withdrawTo( address tokenFrom, address tokenTo, uint256 lpAmount, uint256 minAmountOut, uint256 deadline ) external returns (WithdrawResult memory); function swapLiability( address tokenIn, address tokenOut, uint256 lpAmountIn, uint256 minLpAmountOut, uint256 deadline ) external returns (uint256 lpAmountOut); function donate(address token, uint256 amount) external payable; function getSwapQuote(address tokenIn, address tokenOut, uint256 amountIn) external view returns (SwapQuote memory); function previewWithdraw(address token, uint256 lpAmount) external view returns (uint256, uint256); function getCoverageRatio(address token) external view returns (uint256); function getLPBalance(address user, address token) external view returns (uint256); function lpToken(address token) external view returns (address); function getRiskFlags(address token) external view returns (uint16); function getProtocolFees(address token) external view returns (uint256); function treasury() external view returns (address); function flowCooldownSecs() external view returns (uint16); }

The generated ABIs are also served live at GET /v1/abis/{Pool,Admin,Flash,PoolFactory,ExternalOracleV4,ExternalOracle,AccessControl,LPToken}; see the API & SDK Reference.

On each supported chain, one shared Pool implementation plus one Admin and one Flash serve every pool on that chain. The standards each piece sits on:

  • Pools are ERC-1967 beacon proxies.
  • LP receipts are ERC-20.
  • Flash is ERC-3156-style with a postFlashLoan callback.
  • Guards use ERC-1153 transient storage.

Per-chain addresses come from GET /v1/venues.


2. Check the flags first

Every entry point gates on the leg’s risk flags before anything else. Read them with getRiskFlags(token) and test the bit for the operation you are about to attempt: a swap-enabled leg is not necessarily liability-swap-enabled.

BitValueMeaning
HALT_RISK_BIT1 << 0 = 1Risk halt is set on this leg
SWAP_ENABLED_BIT1 << 1 = 2swap permitted
LIABILITY_SWAP_ENABLED_BIT1 << 2 = 4swapLiability permitted
FLASH_ENABLED_BIT1 << 4 = 16flash loans permitted
HALT_GUARDIAN_BIT1 << 6 = 64Guardian halt is set on this leg

Halt bits refcount by source: a guardian halt and an owner risk halt are separate bits, and clearing one does not relist a leg the other still holds down.


3. Swap

Runnable, tested: foundry/test/03_PoolSwap.t.sol (Solidity, forked) · sdk/03-pool-swap.ts · rpc/03-pool-swap.ts (no SDK).

function swap( address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, address recipient, uint256 deadline // inclusive: reverts only when block.timestamp > deadline ) external payable returns (uint256 amountOut);
  1. Quote. getSwapQuote(tokenIn, tokenOut, amountIn) is a view returning the full SwapQuote: output, spread, both fee slices, coverage toll, mark and mid. Derive minAmountOut from amountOut and your own tolerance.
  2. Approve. Non-native: approve(pool, amountIn). Native: send it in msg.value.
  3. Execute. Pool.swap prices against the external mark, applies the coverage toll and fee, then updates reserves. No oracle write happens on a swap.
  4. Protect. The call reverts if amountOut < minAmountOut.

Pool.swap pulls tokenIn from msg.sender, so the call must originate from the account holding the balance and allowance. It cannot be wrapped in Multicall3; see Quotes & Routing §2.

SDK (@btr-protocol/sdk/pool, first argument is an EIP-1193 provider):

import { getSwapQuote, swap, defaultDeadline } from '@btr-protocol/sdk/pool'; const quote = await getSwapQuote(provider, pool, tokenIn, tokenOut, amountIn); const minOut = (quote.amountOut * 9950n) / 10000n; // 50 bps - see §7 const txHash = await swap(provider, pool, { tokenIn, tokenOut, amountIn, minAmountOut: minOut, recipient: user, deadline: defaultDeadline(), });

4. Deposit & withdraw

Runnable, tested: deposit 04_Deposit.t.sol · sdk/04-deposit.ts · rpc/04-deposit.ts. Withdraw 05_Withdraw.t.sol · sdk/05-withdraw.ts · rpc/05-withdraw.ts.

function deposit(address token, uint256 amount) external payable returns (DepositResult memory); function withdraw(address token, uint256 lpAmount, uint256 minAmountOut, uint256 deadline) external returns (WithdrawResult memory); function withdrawTo( address tokenFrom, address tokenTo, uint256 lpAmount, uint256 minAmountOut, uint256 deadline ) external returns (WithdrawResult memory);

LP accounting. Each leg has its own ERC-20 share receipt: one EIP-1167 clone per (pool, leg), address from pool.lpToken(token). Read a balance with pool.getLPBalance(user, token) or the receipt’s own balanceOf. Shares become transferable once past the anti-JIT cooldown stamped at mint (flowCooldownSecs(), default 15 s). Price an exit off pool.previewWithdraw, never off decimals().

Deposit. token.approve(pool, amount), then pool.deposit(token, amount). Shares mint to the caller on the leg receipt. If the deposit opens the leg, DepositResult.deadLp shares are sunk to address(0) and are not yours.

Withdraw. pool.withdraw(...) for a same-asset exit, pool.withdrawTo(...) to burn on one leg and receive another (priced along the anchor path).

Haircut. Fires only at coverage below 100%, and protects the LPs who stay. Formula and worked examples: Inventory Management §5.

import { deposit, withdraw, getLPBalance, defaultDeadline } from '@btr-protocol/sdk/pool'; await deposit(provider, pool, { token, amount }); const lpAmount = await getLPBalance(provider, pool, user, token); await withdraw(provider, pool, { token, lpAmount, minAmountOut, deadline: defaultDeadline() });

deposit is payable: pass the EIP-7528 native sentinel NATIVE_TOKEN (0xEeee…eEEeE) as token and the SDK sends amount as msg.value.


5. Swap liability

function swapLiability( address tokenIn, // leg to leave address tokenOut, // leg to enter uint256 lpAmountIn, uint256 minLpAmountOut, uint256 deadline ) external returns (uint256 lpAmountOut);

Moves an LP claim from one leg to another. Reserves do not move: this re-denominates a liability, it does not trade. A haircut may apply on an under-covered input leg.

An LP holding a balanced ETH/USDC position who expects ETH to fall can sell ETH liabilities for USDC liabilities and end up USDC-heavy without leaving the pool. No first-class SDK sender exists; encode against POOL_ABI.

Four things separate a debt swap from a swap, and getting any of them wrong produces a call that either reverts or, worse, books the wrong number:

  1. Reserves do not move. Only the liability side of two legs changes. Nothing enters or leaves the pool, and nothing reaches the caller’s wallet. An indexer that treats it as a trade will book phantom volume.
  2. Both amounts are LP SHARES, not token units. minLpAmountOut is a SHARE floor. Passing a token amount there is a units bug that a passing test will not catch, because the call still succeeds.
  3. Shares are not comparable across legs. Each leg carries its own liquidity index, so the output share count legitimately differs from the input one at identical value. Compare VALUE, through previewWithdraw on each side, or you will conclude liquidity vanished when it did not.
  4. An under-covered input leg takes a haircut. Leaving a leg that cannot pay everyone in full, at face value, would hand the shortfall to whoever stays. The haircut is applied before the mark conversion, and a round trip therefore strictly loses: a strategy that flips legs on every signal pays for it each time.

Both legs need LIABILITY_SWAP_ENABLED_BIT (bit 2) in getRiskFlags (§2). Check it BEFORE building the call: the revert does not say which of the two legs was the problem.

There is no previewSwapLiability. To learn the output share count off chain, eth_call the real function: it is a state-changing signature, so the call is simulated and never mined.

Runnable, tested: 06_DebtSwap.t.sol · sdk/06-debt-swap.ts · rpc/06-debt-swap.ts.


6. Units

Four scales appear across the protocol and they are not interchangeable.

UnitScaleUsed by
BPS10 000 = 100%kappaCovBps, vegaBps, refBandBps, confidenceBps, maxDeviationBps
PBPS1 000 000 = 100%spreadPbps, minFeePbps, minDispersionPbps, flashFeePbps, sigmaPbps, dispRefPbps
WAD1e18 = 1.0markPrice, midPrice, mark1e18, liquidityIndexWad
Percent100 = 100%protoSharePct only
Half-scale20 000 = 100%haircutSuppressorBps; see below

vegaBps = 10000 means a 1.0× multiplier, not 100%. minFeePbps = 100 is 1 bp. flashFeePbps = 5 is 0.0005%.

haircutSuppressorBps does not use the BPS denominator. The suppressed fraction is haircutSuppressorBps / HAIRCUT_SUPPRESSOR_FULL_BPS, and that constant is 2 × BPS = 20 000. So the natural-looking 10000 is half suppression, not full, and it is the value initAsset seeds. The 2× is deliberate: config caps strictly below 20000, so full suppression is unreachable and some deficit is always socialized to the leg’s LPs. Writes must satisfy haircutSuppressorBps < 20000, and == 0 whenever kappaCovBps > 0.

spreadPbps saturates; it does not revert. SwapQuote.spreadPbps is a uint16, and a composed path spread above 65 535 PBPS is clamped to 65 535 rather than rejected. What saturates away is the σ, confidence and staleness premium (a risk surcharge, not a security floor), because reverting would halt a legitimately wide quote (both endpoints at a 5% minFeePbps with confidence at the halt cap is a live shape). The interior fence is what fails closed, and it is bounded by construction: the widest path the tree admits carries 6 interior legs, so the composed fence is at most 60 306, below the uint16 ceiling.

The consequence for a caller: at an extreme spread the quote under-reports the premium and the swap still executes. Do not treat spreadPbps as a sufficient risk gate near its ceiling. Bound execution with minAmountOut (§7), which is derived from amountOut and is unaffected by the clamp.


7. Slippage protection

Always pass a real minAmountOut. 0 accepts any output.

// Safe swap(tokenIn, tokenOut, amountIn, minAmountOut, recipient, deadline); // Unsafe: accepts any output swap(tokenIn, tokenOut, amountIn, 0, recipient, deadline);

No configuration field bounds the quoted spread from above: the σ, confidence and staleness terms widen it freely, and there is no ceiling parameter an operator can set. The only hard limit is the uint16 saturation at 65 535 PBPS described in §6, which clamps the reported number without blocking the trade. minAmountOut is therefore the only real bound on your execution: derive it from a fresh getSwapQuote rather than from any config field or from spreadPbps.

Working defaults, applied to quote.amountOut:

Trade sizeTolerance
< $10k10–50 bps
$10k – $100k50–100 bps
> $100k100 bps and up, sized against the depth ladder

Stable-to-stable legs sit at the bottom of each band; volatile legs at the top. Splits and multi-hop plans apply the tolerance per leg; see Quotes & Routing §5.


8. Reverts

Errors are shared custom errors from the Err library, so they decode identically across Pool, Admin, Flash and PoolFactory. Merge the library errors into POOL_ABI (the shipped SDK ABI already does) so revert data decodes.

ErrorSignatureCommon cause on a trading path
InsufficientAmount(uint256 available, uint256 required)Output below minAmountOut; or a withdrawal below minLiquidity
Expired()block.timestamp > deadline. Also a matured timelock left past its 7-day grace
FeatureDisabled(Resource)The operation’s flag bit is clear: SWAP, LIABILITY_SWAP or FLASH
NotFound(Resource, address)Token is not a listed leg on this pool
StaleData(uint32 age, uint32 maxAge)The mark feed is past its TTL
PriceOutsideRefBand(uint256 markWad, uint256 refWad)Depeg breaker tripped: the leg’s mark left refBandBps of its reference feed
BaseDepegged(uint256 basePriceWad, uint256 deviationBps)The base token itself moved past BASE_DEPEG_HALT_BPS (500 bps)
CooldownActive()LP receipt still inside the anti-JIT window from mint
ThresholdViolation(uint256 value, uint256 threshold)A bounded parameter or capacity limit was exceeded
NotAuth()Caller is not the authority for a gated call
ZeroValue / ZeroAddr()Zero amount, or a zero address where one is required
InvalidState()Pool not initialized, or bootstrap already sealed
Overflow()Arithmetic bound exceeded

Resource is the enum {ASSET, ORACLE, FEED, TREASURY, GOVERNANCE, SWAP, LIABILITY_SWAP, FLASH, TRANSFER}.

A swap that reverts FeatureDisabled(SWAP) or StaleData is a live protocol state, not a bug in your call. Re-read the flags and the feed before retrying.


9. Events

Indexers should key off these. Swapped, Deposited, Withdrawn, LiabilitySwapped and Donated are emitted by the pool.

event Swapped( address indexed sender, address indexed recipient, address indexed tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint16 spreadPbps, uint256 protoFee, uint256 lpFee, uint256 markPrice, uint256 midPrice, uint256 covToll ); event Deposited(address indexed sender, address indexed token, uint256 amount, uint256 lpAmount); event Withdrawn(address indexed sender, address indexed token, uint256 amount, uint256 lpAmount); event Donated(address indexed sender, address indexed token, uint256 amount); event DeadSharesSeeded(address indexed token, uint256 value, uint256 lpAmount); // The one an indexer needs for NAV: every movement of a leg's liquidity index, // including hook yield credits and write-downs. `reason` discriminates the source. event IndexUpdated( address indexed token, uint256 index, uint128 reserves, uint128 liabilities, uint8 reason ); event LiabilitySwapped( address indexed sender, address indexed tokenIn, address indexed tokenOut, uint256 lpAmountIn, uint256 lpAmountOut, uint256 haircut );

Only three parameters are indexed on Swapped; tokenOut is not, so a filter on the outbound side has to be done off-log. protoFee, lpFee and covToll are denominated in tokenOut.


10. Arc testnet quickstart

See deployed instances for the current fleet. Every instance is a real deployment, not a sandbox.

Gas is paid in native USDC. USDC is the chain’s native currency at the predeploy address 0x3600000000000000000000000000000000000000, so the gas token and the pools’ base token are the same asset. Fund an address once and it can both transact and trade.

  1. Get addresses. Everything comes from one call; never hardcode:

    curl -s https://api.btr.markets/v1/venues | jq '."5042002"'

    Three maps in that payload:

    • contracts carries poolFactory, admin, flash, oracle, refOracle, guardian, treasury and a faucet.
    • tokens maps every listed symbol to its address.
    • feeds maps every <SYM>-USDC pair to its feed id.
  2. Fund the account from the faucet contract in that payload.

  3. Pick a pool and a live pair.

    curl -s https://api.btr.markets/v1/pools | jq '.pools[] | {address, tag, symbols}'

    Four pools are deployed: btr-stable, btr-fx, btr-crypto and btr-stocks. Each lists the symbols tradeable on it, and every one is USDC-based. btr-stocks currently ships with no fitted equity legs, so start on one of the other three.

  4. Check the feed is alive before quoting. /v1/md/tickers reports status and age_ms per ticker; a dead feed keeps its last mid and will revert StaleData on chain.

  5. First swap. Approve the pool for tokenIn, call getSwapQuote, then swap with a minAmountOut derived from it (§7) and a deadline.

RPC endpoint, explorer and the full address tables: Deployments and Contract Addresses.