---
title: "Protocol Fee Collection"
description: "How a pool's fee recipient pulls accrued protocol fees, and how the fee sink is rotated"
audience: tech
type: reference
status: live
lang: en
updated: "2026-09-04"
publish: true
---
# 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

```solidity
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.

```typescript
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:

```solidity
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](/docs/5-1-2-pool-deployment-curation#22-ownership-model)):

```solidity
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

| 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](/docs/3-2-deployment-upgrades#61-timelock-mechanics)).

Full revert catalogue: [Basic Operations §8](/docs/5-1-1-basic-operations#8-reverts).

---

## 5. Related

- [Incentivization §2](/docs/5-3-3-incentivization#2-fee-split): setting `protoSharePct`, the paying side
- [Access Control & Roles](/docs/3-1-access-control-roles-emergency-powers): authorities and rotation
- [Spread & Fees](/docs/1-1-4-spread-fees): how the fee being split is computed
