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

Fee-on-Transfer DoS in Good-Faith CORRUPTED Path — Funds Stuck 180 Days

Author Revealed upon completion

Description

Root + Impact

Description

  • In good-faith CORRUPTED: bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus (entire pool).

  • claimAttackerBounty() pays min(remaining, freeBalance)

  • claimCorrupted() requires bountyClaimed >= bountyEntitlement (MustClaimBountyFirst)

  • sweepUnclaimedCorrupted() only available after corruptedClaimDeadline (180 days)

  • With fee-on-transfer: attacker claims → pool sends X, token takes fee → pool balance drops more than bountyClaimed increments. Pool depletes before bountyClaimed >= bountyEntitlement. claimCorrupted reverts MustClaimBountyFirst. Recovery must wait 180 days.

// claimAttackerBounty() - sends full accounted amount without balance-diff
@> stakeToken.safeTransfer(msg.sender, amount);
// claimCorrupted() - requires full accounted bounty claimed
@> if (bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();

Risk

Likelihood:

  • Same token assumption as fee-on-transfer or rebasing tokens cause balance/accounting divergence

  • Good-faith CORRUPTED path is designed for attacker to claim entire pool, making the fee impact severe

Impact:

  • Attacker cannot claim full entitled bounty due to fee erosion

  • Recovery cannot call claimCorrupted() until bountyClaimed >= bountyEntitlement — reverts MustClaimBountyFirst

  • Funds stuck in pool for 180 days until sweepUnclaimedCorrupted() becomes available


Proof of Concept

// 1% fee token
pool = _deployPool(feeToken);
// ... setup good-faith CORRUPTED ...
vm.prank(attacker);
pool.claimAttackerBounty(); // First claim
// Logs show:
// Bounty claimed: 178200000000000000000
// Bounty entitlement: 178200000000000000000
// Pool balance: 0
// But actual received < accounted due to fee
// claimCorrupted reverts MustClaimBountyFirst
vm.expectRevert(MustClaimBountyFirst.selector);
pool.claimCorrupted();
// Funds stuck for 180 days
// FULL TEST
// ============================================================
// M-1: Fee-on-Transfer DoS in CORRUPTED Path
// ============================================================
function test_M1_FeeOnTransfer_CorruptedPath_DoS() public {
pool = _deployPool(address(feeToken));
_stakeFee(alice, 100 * ONE, feeToken);
_stakeFee(bob, 50 * ONE, feeToken);
_contributeBonusFee(carol, 30 * ONE, feeToken);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, attacker); // Good faith
// bountyEntitlement = 180 * 0.99 = ~178 (accounted after fees on deposit)
// Pool balance = 178
// Attacker claims: pool sends 178, fee takes 1% → attacker gets ~176, pool balance ~0
// But bountyClaimed increments by 178 (accounted amount sent)
// Next claim: remaining = 0, but pool balance ≈ 0
// claimCorrupted reverts NothingToSweep (pool empty)
vm.prank(attacker);
pool.claimAttackerBounty(); // First claim
uint256 bountyClaimed = pool.bountyClaimed();
uint256 bountyEntitlement = pool.bountyEntitlement();
uint256 poolBalance = feeToken.balanceOf(address(pool));
console.log("Bounty claimed:", bountyClaimed);
console.log("Bounty entitlement:", bountyEntitlement);
console.log("Pool balance:", poolBalance);
// Due to fee on transfer, attacker gets less value but accounting matches
// Pool balance depleted
// claimCorrupted reverts NothingToSweep (not MustClaimBountyFirst)
vm.expectRevert(IConfidencePool.NothingToSweep.selector);
pool.claimCorrupted();
// Attacker received less than full value due to transfer fees
// Pool insolvent for any remaining operations
assertEq(poolBalance, 0, "Pool depleted by fee-on-transfer");
}

Recommended Mitigation

+ function _safeTransferWithAccounting(address to, uint256 accountedAmount) internal {
+ uint256 balanceBefore = stakeToken.balanceOf(address(this));
+ stakeToken.safeTransfer(to, accountedAmount);
+ uint256 balanceAfter = stakeToken.balanceOf(address(this));
+ uint256 actuallySent = balanceBefore - balanceAfter;
+ if (actuallySent < accountedAmount) {
+ revert TransferFeeDetected();
+ }
+ }
+
function claimAttackerBounty() external nonReentrant {
// ...
- stakeToken.safeTransfer(msg.sender, amount);
+ _safeTransferWithAccounting(msg.sender, amount);
}
// Also apply to claimCorrupted() and sweepUnclaimedCorrupted()

Support

FAQs

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

Give us feedback!