Root + Impact
stake() and contributeBonus() use balance-diff to measure actual tokens received, correctly handling fee-on-transfer tokens. All outbound transfers (withdraw, claim*, sweep*) use direct safeTransfer(amount) without balance-diff. When the stake token deducts a transfer fee, recipients receive less than entitled. The shortfall accumulates in the contract as unbacked liabilities.
Description
stake() measures actual receipt via balance-diff. If the token takes 1% on a 100-token deposit, the protocol credits 99 tokens:
uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
Every outbound transfer sends the recorded amount without re-measuring:
stakeToken.safeTransfer(msg.sender, amount);
stakeToken.safeTransfer(msg.sender, payout);
When the token deducts a fee on the outgoing transfer, the user receives less than the protocol recorded as owed. The lost fee stays in the contract and is eventually swept to recoveryAddress — not returned to the staker.
The README states fee-on-transfer tokens are not supported. This finding is about the incomplete defense: the balance-diff on inbound creates a false expectation that the protocol handles such tokens, while outbound paths lack the corresponding protection.
Risk
Likelihood:
-
The factory owner adds a token to allowedStakeToken that charges a transfer fee — either at allowlist time or through a future proxy upgrade. The README disclaims this, but no on-chain check prevents it.
-
A fee-on-transfer token is used as the stake token. Every outbound transfer deducts a fee the protocol does not account for.
Impact:
-
Each staker receives less than their recorded eligibleStake or payout on every withdraw or claim. The shortfall accumulates in the contract and is swept to recoveryAddress, not returned to the staker.
-
The accounting variables (totalEligibleStake, claimedBonus) diverge from the actual token balance, making true solvency unverifiable through accounting alone.
Proof of Concept
The test deploys a pool with MockFeeOnTransferERC20 (1% fee on every transfer). Alice deposits 1000 tokens and immediately withdraws.
Timeline and math:
Alice deposits 1000 tokens into the pool.
The 1% fee deducts 10 tokens on the inbound transfer. The protocol's balance-diff correctly credits Alice with 990 tokens (eligibleStake[alice] == 990).
Alice calls withdraw(). The protocol calls stakeToken.safeTransfer(alice, 990).
The 1% fee deducts ~9.9 tokens on the outbound transfer. Alice receives ~980 tokens, not the 990 the protocol recorded.
Alice lost 1% on entry (990 credited instead of 1000) and 1% on exit (980 received instead of 990). The 9.9-token fee stays in the pool contract permanently — it can only be swept to recoveryAddress, not returned to Alice.
Test output:
[PASS] test_L1_withdrawLosesOnePercent() (gas: 292795)
[PASS] test_L1_withdrawCreditedAmountLessThanDeposited() (gas: 292342)
Both tests confirm: the protocol correctly credits 990 tokens on deposit, but Alice receives only ~980 on withdrawal — a double loss from the asymmetric fee handling.
pragma solidity 0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockFeeOnTransferERC20} from "test/mocks/MockFeeOnTransferERC20.sol";
import {MockAttackRegistry} from "test/mocks/MockAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract TestL1_BalanceDiffAsymmetry is BaseConfidencePoolTest {
MockFeeOnTransferERC20 internal feeToken;
ConfidencePool internal feePool;
function setUp() public override {
super.setUp();
feeToken = new MockFeeOnTransferERC20(100);
feePool = _deployFeePool(address(feeToken));
}
function test_L1_withdrawLosesOnePercent() external {
uint256 depositAmount = 1000 * ONE;
feeToken.mint(alice, depositAmount);
vm.startPrank(alice);
feeToken.approve(address(feePool), depositAmount);
feePool.stake(depositAmount);
vm.stopPrank();
uint256 credited = feePool.eligibleStake(alice);
assertEq(credited, depositAmount * 990 / 1000);
uint256 before = feeToken.balanceOf(alice);
vm.prank(alice);
feePool.withdraw();
uint256 received = feeToken.balanceOf(alice) - before;
assertEq(received, (credited * 990) / 1000, "Fee deducted on both entry and exit");
}
function test_L1_withdrawCreditedAmountLessThanDeposited() external {
uint256 depositAmount = 1000 * ONE;
feeToken.mint(alice, depositAmount);
vm.startPrank(alice);
feeToken.approve(address(feePool), depositAmount);
feePool.stake(depositAmount);
vm.stopPrank();
uint256 credited = feePool.eligibleStake(alice);
assertEq(credited, depositAmount * 990 / 1000, "Entry fee: credited 990 of 1000");
uint256 before = feeToken.balanceOf(alice);
vm.prank(alice);
feePool.withdraw();
uint256 received = feeToken.balanceOf(alice) - before;
assertEq(received, (credited * 990) / 1000, "Exit fee: received less than credited");
}
function _deployFeePool(address feeToken_) internal returns (ConfidencePool) {
ConfidencePool impl = new ConfidencePool();
ConfidencePool p = ConfidencePool(Clones.clone(address(impl)));
address[] memory scope = _defaultScope();
p.initialize(
agreement, feeToken_, address(safeHarborRegistry),
moderator, block.timestamp + 31 days, ONE,
recovery, address(this), scope
);
return p;
}
}
Recommended Mitigation
Apply the same balance-diff pattern to outbound transfers. For withdraw():
function withdraw() external nonReentrant {
uint256 amount = eligibleStake[msg.sender];
if (amount == 0) revert InvalidAmount();
// ... existing accumulator cleanup ...
- stakeToken.safeTransfer(msg.sender, amount);
+ uint256 balanceBefore = stakeToken.balanceOf(address(this));
+ stakeToken.safeTransfer(msg.sender, amount);
+ uint256 balanceAfter = stakeToken.balanceOf(address(this));
+ uint256 transferred = balanceBefore - balanceAfter;
+ if (transferred < amount) {
+ totalEligibleStake -= amount - transferred;
+ }
emit Withdrawn(msg.sender, transferred);
}
Apply the same pattern to claimSurvived() and claimExpired() around the final safeTransfer. The sweepUnclaimedBonus() function already reads balanceOf(this) before the sweep and caps at freeBalance - reserved, so it naturally handles fee tokens.