Protocol Fee Collection
Swap and flash spread is split at quote time: a protoSharePct slice is the protocol fee, the remainder raises the LP liquidity index (Spread & Fees). The protocol slice is not paid out per swap. It is booked as pending LP shares of the output leg, and the treasury claims them with one permissioned call on the Admin singleton, per pool and per asset.
1. Where fees sit
A pool’s fee sink is a plain address, not a contract: Pool.treasury(), expected to be a multisig, per pool and per chain (treasury). Pool.initialize seeds it from AccessControl.treasury() and reverts ZeroAddress if that is unset; it moves afterwards only through an UPDATE_TREASURY op.
Both fee slices stay in the leg’s reserves. At settlement the LP slice raises liquidityIndexWad; the protocol slice is booked as face at the pool rate (divided by C when C > 1), converted to shares at the raised index and added to the leg’s pending treasury shares, with the same face added to liabilities. Booking is neutral to C, and no separate fee balance exists: reserves are never split into “pool” and “fee” parts.
Read the pending amount with Pool.treasuryShares(token). The leg’s first deposit buys the treasury one share at par, and a claim always leaves one behind; the getter hides that placeholder, so it returns 0 until real fees accrue.
An LP exit’s shortfall below face is not a protocol fee. It stays in pool reserves and raises the pool rate for the LPs who remain.
2. Claiming
function claimTreasury(address pool, address token) external;One check, at the trust boundary: msg.sender must equal IPool(pool).treasury(), the per-pool value, not a global AccessControl.treasury(). Fee custody is per-pool routable, and the only writer of that value is the UPDATE_TREASURY queue. The call is treasury-gated because it mints: each mint re-arms the receipt’s anti-JIT lock on the recipient, and a permissionless claim would let anyone grief the treasury’s lock.
The claim runs Pool.adminClaimTreasury(token) (callable only by Admin). It first folds any untracked balance of the token, tokens sent to the pool outside a swap or deposit, into the leg as treasury shares at the pool rate, capped at the leg’s liabilities per call and skipped for rebasing or fee-on-transfer legs. It then mints the pending shares to the treasury as the leg’s LP token, keeping one share so the custody word never returns to zero. With fewer than two stored shares it mints nothing.
No recipient argument: shares always go to pool.treasury(), so the capability is “the fee recipient may claim its own fees” and cannot be widened at the call site. What the treasury holds afterwards is an ordinary LP receipt: it earns the index like any other, and exits through withdraw at the pool rate, with the same coverage and liquidity limits as every LP.
import { Contract } from '@btr-protocol/sdk/eth';
import { ADMIN_ABI, POOL_ABI } from '@btr-protocol/sdk/abis';
const pool = new Contract({ address: poolAddress, abi: POOL_ABI, provider });
const pending = await pool.read<bigint>('treasuryShares', [token]);
const admin = new Contract({
address: adminAddress, // the Admin PROXY
abi: ADMIN_ABI,
provider,
account: treasuryAddress, // must equal pool.treasury()
});
if (pending > 0n) await admin.write('claimTreasury', [pool.address, token]);The event is declared on IAdmin, not on the pool, and reports shares minted, not a token amount:
event TreasuryClaimed(address indexed pool, address indexed token, uint256 shares);When the fold books a balance, the pool also emits UntrackedSynced(token, amount).
3. Rotating the recipient
Timelocked at the GOVERNANCE tier, 7 days under PROD_DELAYS (tier table):
admin.requestOp(pool, uint8(IPool.OpType.UPDATE_TREASURY), bytes32(0), abi.encode(newTreasury));
// … wait the GOVERNANCE delay …
admin.executeUpdateTreasury(pool); // emits TreasuryUpdatedUPDATE_TREASURY is pool-wide, so its subject is ignored: pass bytes32(0). Cancel before execution with admin.cancelOp(pool, uint8(IPool.OpType.UPDATE_TREASURY), bytes32(0)): owner or any guardian on a pool with no seat, only the seat on a seated pool.
4. Reverts
| Error | Cause | Fix |
|---|---|---|
NotAuthorized | claimTreasury caller is not pool.treasury() | Call from the configured fee recipient |
ZeroAddress | UPDATE_TREASURY payload decodes to the zero address | Queue a non-zero fee sink |
AlreadyPending | An op of this type is already queued for this pool and is still inside eta + GRACE_PERIOD_SECS | Cancel, then re-request |
NotReady | Timelock has not matured | Check the eta from TimelockRequested |
Expired | Matured op left unexecuted past its 7-day grace window | Re-request |
An expired entry does not block a re-request. A request on a key already past eta + GRACE_PERIOD_SECS overwrites it (TimelockCancelled, then TimelockRequested with a full fresh delay), so only a live entry has to be cancelled first (Admin).
Full revert catalogue: Basic Operations §8.
5. Related
- Incentivization §2: setting
protoSharePct, the paying side - Access Control: authorities and rotation
- Spread & Fees: how the fee being split is computed
- Treasury: what the fee sink can and cannot do