Protocol Fee Collection

Swap and flash spread is split at quote time: a protoSharePct slice accrues to the pool as protocol fees, and the remainder raises the LP liquidity index. Collection is 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. It is per pool and per chain. Pool.initialize leaves it zero, so collectProtocolFees reverts NotAuth until an UPDATE_TREASURY op lands.

Fees accrue per asset inside the pool; read the balance with Pool.getProtocolFees(token). They sit in the pool’s own balance, escrowed against reserves, until the recipient pulls them.

Withdrawal haircuts are not protocol fees. They stay in pool reserves and accrue to the remaining LPs.


2. Collecting

function collectProtocolFees(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.

There is no recipient argument. The accrued balance is always pushed to the caller, so the capability is “the fee recipient may pull its own fees” and cannot be widened at the call site.

import { Contract } from '@btr-protocol/sdk/eth'; import { ADMIN_ABI } from '@btr-protocol/sdk/abis'; const admin = new Contract({ address: adminAddress, // the Admin PROXY abi: ADMIN_ABI, provider, account: treasuryAddress, // must equal pool.treasury() }); const tx = await admin.write('collectProtocolFees', [pool, token]);

The event is declared on IAdmin, not on the pool:

event ProtocolFeesCollected( address indexed pool, address indexed token, address indexed recipient, uint256 amount );

3. Rotating the recipient

Timelocked at the HIGH custody tier, 3 days under PROD_DELAYS (tier table):

admin.requestOp(pool, uint8(IPool.OpType.UPDATE_TREASURY), bytes32(0), abi.encode(newTreasury)); // … wait the HIGH delay … admin.executeTreasuryUpdate(pool); // emits TreasuryUpdated

UPDATE_TREASURY is pool-wide, so its subject is ignored: pass bytes32(0). Cancel before execution with admin.cancelTimelock(pool, uint8(IPool.OpType.UPDATE_TREASURY), bytes32(0)), owner or any guardian.


4. Reverts

ErrorCauseFix
NotAuthCaller is not pool.treasury()Call from the configured fee recipient
NotAuthFee sink still unset (pool.treasury() == address(0))Land an UPDATE_TREASURY op first
ZeroAddrUPDATE_TREASURY payload decodes to the zero addressQueue a non-zero fee sink
AlreadyPendingAn op of this type is already queued for this pool and is still inside eta + GRACE_PERIODCancel, then re-request
NotReadyTimelock has not maturedCheck the eta from TimelockRequested
ExpiredMatured op left unexecuted past its 7-day grace windowRe-request

An expired entry does not block a re-request. Shipping in the next release, a request on a key already past eta + GRACE_PERIOD overwrites it — TimelockCancelled then TimelockRequested, full fresh delay — so only a live entry has to be cancelled first (Deployment & Upgrades §6.1).

Full revert catalogue: Basic Operations §8.