How Our 5-Way Fee Router Distributes Protocol Revenue On-Chain
9 min read
Most DeFi protocols describe fee distribution as a one-liner in their docs: "fees go to stakers and the treasury." The actual mechanics of how fees move from one contract to five destinations without rounding errors, reentrancy vulnerabilities, or solvency hazards are almost never documented. This post opens up the FeeRouterFacet on the THRYX Diamond (0x2F77b40c124645d25782CfBdfB1f54C1d76f2cCe) and walks through every step.
Why a fee router exists at all
THRYX collects protocol fees from three sources: the 30% protocol cut on every swap (SwapFacet), anti-sniper hook fees forwarded from ThryxAntiSniperHook contracts, and LP position fees collected by LpFeeCollectorFacet from Diamond-owned Uniswap positions. All three deposit WETH on the Diamond and call notifyIncomingWeth(amount) to register the amount. Without a router, each source would need its own splitting logic, its own treasury transfer, and its own staker accounting. FeeRouterFacet centralizes the split so fee sources only need to know one function signature.
The two-phase pattern: notify then distribute
The deposit and the split are intentionally decoupled into two transactions. Phase 1: a fee source calls notifyIncomingWeth(amount), which adds to a pendingDistributionWei counter. The WETH is already on the Diamond at this point (deposited before the notify call). Phase 2: anyone calls distributePending(), which snapshots the counter, zeros it (checks-effects-interactions pattern), and applies the five-way split. This decoupling means a fee source can deposit at any time without worrying about gas costs of the split, and the split can be batched across multiple deposits.
// Simplified from FeeRouterFacet.sol
function distributePending() external nonReentrant returns (uint256) {
ThryxState storage s = LibThryxStorage.state();
uint256 total = s.pendingDistributionWei;
if (total == 0) return 0;
s.pendingDistributionWei = 0; // CEI: zero before external calls
uint256 stakerShare = total * s.feeRouteStakersBps / 10000;
uint256 burnShare = total * s.feeRouteBurnBps / 10000;
uint256 treasuryShare = total * s.feeRouteTreasuryBps / 10000;
uint256 insuranceShare = total * s.feeRouteInsuranceBps / 10000;
// Dust goes to paymaster — sum of four shares may be < total
uint256 paymasterShare = total - stakerShare - burnShare
- treasuryShare - insuranceShare;
// ... route each share to its destination
}
The five buckets explained
The staker share is forwarded to StakingFacet via an intra-Diamond self-call: address(this).call(abi.encodeWithSignature("notifyRewardWei(uint256)", stakerShare)). This routes through the Diamond's selector dispatch table and lands in StakingFacet, which bumps the global accRewardPerShareWei accumulator. Every NEW staker's pending WETH reward increases proportionally to their stake. The self-call pattern means FeeRouterFacet never imports StakingFacet's code and the two facets can be upgraded independently.
The burn share accumulates in a pendingBurnWeth counter. An off-chain buyback-burn cron (server/jobs/buyback-burn.js) periodically reads this counter, buys NEW with the accumulated WETH through the v3 pool, and burns the NEW by sending it to address(0xdead). Keeping the burn off-chain avoids embedding V4 swap-routing dependencies inside the facet and lets the cron time its buys for favorable pool conditions.
A SafeERC20 WETH transfer to the configured treasuryWallet address. This is the only bucket that moves WETH off the Diamond in the same transaction. The treasury wallet is set via setTreasuryWallet() (owner-only) and emits a TreasuryWalletSet event on every change.
A logical bump on s.paymasterEthBalance. The paymaster keeper (server/lib/paymaster-keeper.js) reads this storage slot, unwraps WETH to ETH as needed, and uses it to sponsor gas grants for users. The rounding dust from integer division always lands here. In the worst case (total = 1 wei), one bucket gets 1 wei and the other four get 0. The paymaster is the designated dust collector because gas sponsorship is the most fungible use and the smallest protocol cost per unit.
A logical bump on s.insuranceBalanceWei. The insurance fund is a backstop the protocol can tap via withdrawInsurance() (owner-only) to make stakers whole if an external event (sandwich attack, oracle failure, pool drain) creates a solvency gap. The solvency-monitor cron includes insurance balance when computing the Diamond's total resources, so a healthy insurance fund prevents false-alarm insolvency alerts.
The staker-debt guard
The most critical safety mechanism is not in the facet itself but in the fee-router-keeper that calls it. Before calling distributePending(), the keeper projects the post-distribute solvency: it computes (a) how much WETH will leave the Diamond (the treasury share), (b) how much the staker debt will grow (the staker share, since accRewardPerShareWei increases), and (c) whether Diamond WETH after treasury exit exceeds the new staker debt.
// From server/jobs/fee-router-keeper.js — projection logic
const distributeTotal = pendingAlready + wethAfter;
const treasuryShareProj = (distributeTotal * treasuryBps) / 10000n;
const stakerShareProj = (distributeTotal * stakersBps) / 10000n;
const wethProj = wethBal + wrapAmount - treasuryShareProj;
const newStakerDebt = stakerDebt + stakerShareProj;
if (wethProj <= newStakerDebt) {
// Skip: distributing would make staker claims insolvent
return;
}
This guard exists because of a real incident on 2026-05-24. The fee-router-keeper had a BEFORE-only check (is Diamond WETH above staker debt right now?) which passed, but after distribution the treasury share LEFT the Diamond while the staker share ADDED to the accumulator debt. Repeated cycles dragged surplus negative within hours. The post-distribute projection fixed it permanently.
Tuning the split
The owner can call setFeeRoute(stakersBps, burnBps, treasuryBps, paymasterBps, insuranceBps) to change the allocation. The function enforces that all five parameters sum to exactly 10,000 bps. The default values (4000/2000/2500/500/1000) are hardcoded as constants in FeeRouterFacet.sol. Any change emits a FeeRouteSet event and takes effect on the next distributePending() call. Current live values are readable via getFeeRoute(), which returns all five bps values plus the current pendingDistributionWei and treasury wallet address.
Verifying on-chain
Call getFeeRoute() on the Diamond (0x2F77b40c124645d25782CfBdfB1f54C1d76f2cCe) via any Base RPC. The return is a tuple: (uint16 stakersBps, uint16 burnBps, uint16 treasuryBps, uint16 paymasterBps, uint16 insuranceBps, uint256 pendingDistributionWei, address treasuryWallet). Every distributePending() execution emits a Distributed event with the exact wei amounts routed to each bucket, which can be verified on basescan.org.
Frequently asked questions
Frequently asked
- What happens if the staker-debt guard blocks distribution for days?
- The pendingDistributionWei counter keeps growing. No WETH is lost. Once the Diamond's WETH surplus recovers (from new trading fees, hook sweeps, or LP fee collection), the guard passes and the accumulated pending amount distributes in a single call. The system is designed for eventual consistency, not real-time payouts.
- Can the bps split be changed without a contract upgrade?
- Yes. setFeeRoute() is an owner-only function on the existing FeeRouterFacet. No Diamond cut required. The constraint is that the five values must sum to 10,000. Changing the split takes one transaction and ~50K gas on Base (about $0.001).
- Why does rounding dust go to the paymaster specifically?
- Gas sponsorship is the most fungible use of small amounts. A rounding error of 1-4 wei on a distribution is meaningless for staker yield or treasury, but it adds to the paymaster's ETH balance that sponsors user transactions. Over thousands of distributions, dust accumulates into usable gas grants.
- How does the burn queue work if no one calls the buyback-burn cron?
- pendingBurnWeth accumulates indefinitely on-chain. The off-chain cron (buyback-burn.js) runs every 30 minutes and buys NEW with the accumulated WETH. If the server is down, the WETH just waits. When the server recovers, the next cron cycle processes the entire backlog.
- Is the FeeRouterFacet address the same as the Diamond?
- Yes. FeeRouterFacet is a facet on the Diamond proxy (0x2F77b40c124645d25782CfBdfB1f54C1d76f2cCe). All calls go to the Diamond address; the proxy dispatches to FeeRouterFacet based on the function selector. The facet implementation lives at 0x19fd345e... but users never call it directly.