FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: medium
Likelihood: medium

fee-on-transfer / rebasing token compatibility: claims under-pay or later claims lock permanently

Author Revealed upon completion

Root + Impact

Description

  • The pool assumes the stake token is a standard ERC20 (no transfer fees, no rebasing). The deposit side records the actually-received amount via balance-diff (received = balanceAfter − balanceBefore), so fee-on-transfer deposits are credited correctly; after resolution, each claim / withdraw / sweep pays out the accounted liabilities.

  • The compatibility bug is on the outbound side and the balance assumption: the contract calls safeTransfer with the accounted payout, implicitly assuming "pool balance is always ≥ tracked liabilities, and the recipient receives exactly what is sent." For a fee-on-transfer token the recipient gets payout − fee, so every claim/withdraw is silently under-paid; for a negative-rebasing / fee-on-sender token the pool balance erodes independently of tracked liabilities until balanceOf(pool) < tracked liabilities, so the last claim reverts on insufficient balance and principal is permanently locked. There is no on-chain guard or token-type probe.

// src/ConfidencePool.sol
function claimSurvived() external nonReentrant {
...
uint256 bonusShare = _bonusShare(msg.sender, userEligible);
uint256 payout = userEligible + bonusShare; // computed from accounted liabilities
totalEligibleStake -= userEligible;
...
@> stakeToken.safeTransfer(msg.sender, payout); // assumes recipient receives == payout and balance suffices
}
// src/ConfidencePoolFactory.sol
function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
@> allowedStakeToken[token] = allowed; // no token-type check; FoT/rebasing can slip in
emit StakeTokenAllowedUpdated(token, allowed);
}

Risk

Likelihood:

  • The factory owner adds a fee-on-transfer or rebasing token to allowedStakeToken. This is not an exotic mistake: stETH (rebasing), PAXG (0.02% transfer fee), and USDT (a built-in owner-toggleable fee switch) are all mainstream blue-chips an operator could plausibly allowlist as safe collateral

  • An already-allowlisted token (e.g. USDT) later enables a fee / becomes rebasing, while existing pools are locked to that token and cannot switch

Impact:

  • Fee-on-transfer: stakers / recovery are under-paid the transfer fee on every claim / withdraw / sweep, leaking value continuously

  • Negative-rebasing / fee-on-sender: the pool balance erodes below tracked liabilities, later claims revert on insufficient balance, and principal is permanently locked

Proof of Concept

(a) Fee-on-transfer under-payment (test/audit/I01_FeeOnTransferCompat.t.sol, 1% fee token):

function testPoC_I01_survivedClaimUnderpaysByFee() external {
uint256 credited = 200 * ONE - (200 * ONE * 100) / 10_000; // deposit credits 198e18
// ... alice stakes 200, resolve SURVIVED ...
uint256 balBefore = feeToken.balanceOf(alice);
vm.prank(alice);
pool.claimSurvived();
uint256 got = feeToken.balanceOf(alice) - balBefore;
assertEq(got, credited - (credited * 100) / 10_000); // receives ~196.02e18
assertLt(got, pool.snapshotTotalStaked()); // less than tracked principal
}

(b) Negative-rebasing permanent lockup (test/audit/I01_RebasingLockup.t.sol):

function testPoC_I01_negativeRebaseLocksLastClaimerPrincipal() external {
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE); // tracked 200, balance 200
token.rebaseDown(address(pool), 50 * ONE); // balance drops to 150, tracked stays 200
attackRegistry.setAgreementState(IAttackRegistry.ContractState.PRODUCTION);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.SURVIVED, false, address(0));
vm.prank(alice);
pool.claimSurvived(); // first claimer paid full 100, balance left 50
vm.prank(bob);
vm.expectRevert(); // bob owed 100, only 50 left → revert, principal locked
pool.claimSurvived();
}
forge test --offline --match-path test/audit/I01_FeeOnTransferCompat.t.sol
forge test --offline --match-path test/audit/I01_RebasingLockup.t.sol

Recommended Mitigation

Prefer rejecting known fee-on-transfer / rebasing tokens at the factory level, and institutionalize the "non-standard ERC20 unsupported" assumption as a pre-listing token probe.

// src/ConfidencePoolFactory.sol
function setStakeTokenAllowed(address token, bool allowed) external onlyOwner {
if (token == address(0)) revert ZeroAddress();
+ // Probe the candidate token for a transfer fee before listing (mint→transfer→compare
+ // balance delta) and reject fee-on-transfer; keep rebasing tokens (stETH-class) off a
+ // manually vetted allowlist.
allowedStakeToken[token] = allowed;
emit StakeTokenAllowedUpdated(token, allowed);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!