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.soldoes not declare the trading functions. The shippedIPoolcarries the shared types (Asset,SwapQuote,OracleConfig,RiskConfig,FeeParams,OpType,HookSlot,DepositResult,WithdrawResult,RoutePath), theadmin*andhook*gated entry points, and a set of views.swap,deposit,withdraw,withdrawTo,donate,swapLiability,getSwapQuote,previewWithdrawandgetCoverageRatioare declared onPool.solonly.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
postFlashLoancallback. - 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.
| Bit | Value | Meaning |
|---|---|---|
HALT_RISK_BIT | 1 << 0 = 1 | Risk halt is set on this leg |
SWAP_ENABLED_BIT | 1 << 1 = 2 | swap permitted |
LIABILITY_SWAP_ENABLED_BIT | 1 << 2 = 4 | swapLiability permitted |
FLASH_ENABLED_BIT | 1 << 4 = 16 | flash loans permitted |
HALT_GUARDIAN_BIT | 1 << 6 = 64 | Guardian 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);- Quote.
getSwapQuote(tokenIn, tokenOut, amountIn)is a view returning the fullSwapQuote: output, spread, both fee slices, coverage toll, mark and mid. DeriveminAmountOutfromamountOutand your own tolerance. - Approve. Non-native:
approve(pool, amountIn). Native: send it inmsg.value. - Execute.
Pool.swapprices against the external mark, applies the coverage toll and fee, then updates reserves. No oracle write happens on a swap. - 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:
- 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.
- Both amounts are LP SHARES, not token units.
minLpAmountOutis a SHARE floor. Passing a token amount there is a units bug that a passing test will not catch, because the call still succeeds. - 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
previewWithdrawon each side, or you will conclude liquidity vanished when it did not. - 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.
| Unit | Scale | Used by |
|---|---|---|
| BPS | 10 000 = 100% | kappaCovBps, vegaBps, refBandBps, confidenceBps, maxDeviationBps |
| PBPS | 1 000 000 = 100% | spreadPbps, minFeePbps, minDispersionPbps, flashFeePbps, sigmaPbps, dispRefPbps |
| WAD | 1e18 = 1.0 | markPrice, midPrice, mark1e18, liquidityIndexWad |
| Percent | 100 = 100% | protoSharePct only |
| Half-scale | 20 000 = 100% | haircutSuppressorBps; see below |
vegaBps = 10000 means a 1.0× multiplier, not 100%. minFeePbps = 100 is 1 bp. flashFeePbps = 5 is 0.0005%.
haircutSuppressorBpsdoes not use the BPS denominator. The suppressed fraction ishaircutSuppressorBps / HAIRCUT_SUPPRESSOR_FULL_BPS, and that constant is2 × BPS = 20 000. So the natural-looking10000is half suppression, not full, and it is the valueinitAssetseeds. The 2× is deliberate: config caps strictly below20000, so full suppression is unreachable and some deficit is always socialized to the leg’s LPs. Writes must satisfyhaircutSuppressorBps < 20000, and== 0wheneverkappaCovBps > 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 size | Tolerance |
|---|---|
| < $10k | 10–50 bps |
| $10k – $100k | 50–100 bps |
| > $100k | 100 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.
| Error | Signature | Common 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.
Get addresses. Everything comes from one call; never hardcode:
curl -s https://api.btr.markets/v1/venues | jq '."5042002"'Three maps in that payload:
contractscarriespoolFactory,admin,flash,oracle,refOracle,guardian,treasuryand afaucet.tokensmaps every listed symbol to its address.feedsmaps every<SYM>-USDCpair to its feed id.
Fund the account from the
faucetcontract in that payload.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-cryptoandbtr-stocks. Each lists the symbols tradeable on it, and every one is USDC-based.btr-stockscurrently ships with no fitted equity legs, so start on one of the other three.Check the feed is alive before quoting.
/v1/md/tickersreportsstatusandage_msper ticker; adeadfeed keeps its lastmidand will revertStaleDataon chain.First swap. Approve the pool for
tokenIn, callgetSwapQuote, thenswapwith aminAmountOutderived from it (§7) and adeadline.
RPC endpoint, explorer and the full address tables: Deployments and Contract Addresses.
11. Related
- Integration examples: every call on this page, runnable and tested against a live deployment
- Quotes & Routing: choosing between pools, splits, EIP-5792 batching
- Cookbook: ready-made call stacks in four languages
- Composability: flash loans and protocol-owned liquidity
- Pool Deployment & Curation: listing assets and configuring a pool
- Spread & Fees · AIMM Overview