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

Balance-Diff Defense Inflates Stake Accounting for Proxy-Upgraded Rebasing Tokens

Author Revealed upon completion

Root Cause + Impact

stake() computes received via balance-diff as defense-in-depth against fee-on-transfer:

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;

There is no ceiling check. When a rebase inflates the pool's balance between the two calls, received > amount. Walkthrough:

  1. Pool holds 1000 tokens. Token rebases +5% → balance silently becomes 1050.

  2. Attacker calls stake(100). balanceBefore=1050, transfer 100, balanceAfter=1150.

  3. received = 150. Attacker credited 150 instead of 100. The 50 extra was the pool's pre-existing rebase gain.

Description

The balance-diff handles received < amount (FOT) but over-credits on received > amount (rebase). The Factory allowlists only at creation time (L77); de-listing doesn't affect existing pools. The test suite only covers FOT (ConfidencePool.fot.t.sol), with no test for received > amount. docs/DESIGN.md does not document this behavior.

Risk

While the precondition (proxy-upgrade token → rebase) is specific, the impact is direct fund loss, not a theoretical edge case. The pool accounting breaks permanently: totalEligibleStake diverges from actual token balance and cannot self-correct. Affected pools remain exploitable until resolution. The same vector in contributeBonus() inflates totalBonus, amplifying the bonus dilution.

Proof of Concept

The PoC simulates a proxy-upgraded token where balanceOf returns an ever-increasing value after a rebase event. Alice stakes legitimately, a rebase triggers, then Bob stakes during the rebase window. Bob is credited more than he transferred, and total tracked stake exceeds actual pool balance.

contract RebaseToken is ERC20 {
uint256 public lastRebase;
constructor() ERC20("RBT", "RBT") {}
function triggerRebase() external { lastRebase = block.timestamp; }
function balanceOf(address a) public view override returns (uint256) {
uint256 b = super.balanceOf(a);
return (lastRebase != 0 && block.timestamp > lastRebase) ? b + (b * 5) / 100 : b;
}
}
function testRebaseInflatesStake() public {
token.mint(alice, 1000e18);
vm.prank(alice); token.approve(address(pool), 1000e18); pool.stake(1000e18);
token.triggerRebase(); vm.warp(block.timestamp + 1);
assertGt(token.balanceOf(address(pool)), 1000e18); // rebase inflated pool
token.mint(bob, 100e18);
vm.prank(bob); token.approve(address(pool), 100e18); pool.stake(100e18);
assertGt(pool.eligibleStake(bob), 100e18); // ❌ over-credited
assertGt(pool.totalEligibleStake(), token.balanceOf(address(pool))); // ❌ accounting broken
}

Recommended Mitigation

Add a ceiling check after computing received in both stake() and contributeBonus():

if (received > amount) revert ExcessiveTokensReceived();

This preserves the FOT defense while preventing rebase inflation.

Support

FAQs

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

Give us feedback!