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 TreasuryUpdatedUPDATE_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
| Error | Cause | Fix |
|---|---|---|
NotAuth | Caller is not pool.treasury() | Call from the configured fee recipient |
NotAuth | Fee sink still unset (pool.treasury() == address(0)) | Land an UPDATE_TREASURY op first |
ZeroAddr | 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 | 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. 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.
5. Related
- Incentivization §2: setting
protoSharePct, the paying side - Access Control & Roles: authorities and rotation
- Spread & Fees: how the fee being split is computed